Requests, Runtime Values, and Effects
The native API is value-oriented. Nauthilus passes immutable or read-only request state into a component and the component returns a result that describes facts, state changes, enforcement, or follow-up work. Plugin code never receives a mutable internal authentication object.
Data-flow model
This boundary provides two guarantees to the host: plugin outputs are explicit and can be validated, and server-owned mutable objects do not escape. It does not sandbox the plugin process itself.
Request snapshots
RequestSnapshot contains copied, redacted request metadata. Its fields fall into these groups:
| Group | Examples | Notes |
|---|---|---|
| Identity | Username, Account, AccountField, UniqueUserID, DisplayName | Values known at the current extension point; fields can be empty when not resolved yet. |
| Transport | ClientIP, ClientPort, ClientNet, ClientHost, LocalIP, LocalPort, UserAgent | Safe value copies, not network objects. |
| Protocol | Service, Protocol, Method, Session, ExternalSessionID | Session is request correlation state; treat it as sensitive even though it is exposed. |
| IdP | IDP.RequestedScopes, client metadata, grant type, redirect URI, MFA method/completion | Populated only when the host has the corresponding IdP context. |
| TLS | normalized TLSInfo and compatibility values below TLS.Legacy | Certificate/key material is never exposed. |
| Diagnostics | status text, brute-force metadata, latency, HTTP status | Bounded current outcome metadata, not a mutable result. |
| Runtime flags | debug, no-auth, user-found, authenticated, authorized, source-stage flags | A snapshot of host-derived booleans at the invocation point. |
| Headers | map[string][]string | Copied and redacted; authorization, cookie, proxy authorization, and configured password-bearing headers are not passed through in clear text. |
The exact field list and population caveats are in Runtime and request values.
Treat every map, slice, and byte sequence as request-local input. Current adapters make defensive copies at the boundary, but mutating input is still misleading and does not update core state.
Credentials and secrets
Passwords never appear in RequestSnapshot or RuntimeContext. A component uses its request's CredentialProvider only after the module successfully requested the credentials capability:
secret, ok := request.Credentials.Password(ctx)
if !ok || secret.IsZero() {
return pluginapi.BackendResult{UserFound: true}, nil
}
matched, err := password.CompareHash(storedHash, secret)
Secret.WithBytes exposes bytes only inside a callback:
err := secret.WithBytes(func(value []byte) error {
return verifyWithExternalLibrary(value)
})
Do not retain the slice, convert it to a long-lived string, attach it to an error, or pass it to a detached worker. Clear any plugin-owned copy immediately after use.
One credentials grant covers every credential-bearing request type of the module: backends, environment and subject sources, synchronous obligations (ObligationRequest.Credentials), and post-actions. Components of a module without the grant receive a provider that reports the password as unavailable. password.CompareHash verifies salted SHA, the common crypt formats, and bcrypt ($2a$, $2b$, $2y$); see the password package.
Runtime context
RuntimeContext is a read-only view:
value, ok := request.Runtime.Get("plugin.exchange.geoip")
allValues := request.Runtime.Snapshot()
Both operations return isolated values. A plugin proposes changes by returning RuntimeDelta:
pluginapi.RuntimeDelta{
Set: map[string]any{
"plugin.environment.customer_risk": map[string]any{
"score": 42,
},
},
Delete: []string{"plugin.environment.customer_risk.previous"},
}
Runtime values may be nil, booleans, strings, finite numeric scalars,
string-keyed maps, slices, and arrays, recursively. Structs such as time.Time
or netip.Addr, pointers, and opaque server/plugin objects are rejected. The
current normalizer has no maximum key length, entry count, slice length, total
size, or recursion depth and does not detect cyclic map/slice graphs. Return
small acyclic values, use stable names, and never use runtime context as a
secret store.
Standard exchange keyspace
Cross-plugin analytics and post-action coordination use plugin.exchange.*, defined by pluginapi/v1/exchange. Bundled examples include:
plugin.exchange.geoip
plugin.exchange.geoip_reputation
plugin.exchange.haveibeenpwnd
plugin.exchange.feature.<name>
plugin.exchange.decision_sources
The historical Lua rt table is not the native exchange contract. Native code should not read or write rt for new integrations.
Producer ownership matters: only the producer for a standard exchange key should replace that key. Consumers should use the defensive normalization helpers from the exchange package instead of assuming one concrete decoded map type.
Policy facts
A PolicyFact is typed evidence for the policy engine:
pluginapi.PolicyFact{
Attribute: "plugin.geoip.country_iso",
Value: "DE",
}
Before any component can emit it, the module registers an AttributeDefinition describing:
- stable ID;
- policy stage and operations;
- category and value type;
- compatible producer types or one exact compiled producer check;
- optional typed detail definitions and sensitivity.
Environment, subject, and obligation facts are validated against the compiled
active policy snapshot for registered ID, value type, stage, and operation.
ProducerTypes and ProducerCheck constrain policy compilation and planning,
but the current request-time validator does not compare them with the component
that emitted the fact; do not treat them as runtime isolation. Backend and
account-list facts currently receive only non-empty-ID and runtime-value
normalization in the plugin adapter before core collection; they do not receive
the same registry/stage/type validation there. Plugin authors must still
register and type them correctly and must not rely on either implementation gap.
A fact is not automatically public log output. Use explicit LogField values or PublicPolicyFactLogField when a selected value is intentionally safe to log.
Dependency-scheduled sources
Environment and subject sources return SourceDescriptor from Descriptor().
Current runtime behavior requires careful reading:
Nameidentifies the local component and is qualified with the module name.Timeoutbounds a call when positive; zero relies on the surrounding context; negative values are rejected.RequiresandAfteraccept local or fully qualified native source names. Local names are qualified during registration.- Cross-family dependencies between Lua and native sources are not supported.
- In the current 3.1 development runtime,
RequiresandAfterare both merged into a hard dependency list. A missing dependency blocks the source;Afteris not merely a soft ordering hint. Priorityis validated/stored/discovered but is not currently used by the native scheduler.AbortPolicyis validated/defaulted/discovered but current native execution does not apply itsnone,source, orrequestdistinctions.
Do not design correctness around Priority or AbortPolicy until the runtime implements them. Treat both dependency lists as required native predecessors in current builds.
Environment sources
An EnvironmentSource runs before backend authentication. It receives snapshot/runtime data and an optional capability-gated credential provider. Its result can carry:
Statusand structuredLogs;- registered
Facts; - a validated
RuntimeDelta; Triggereddiagnostic state;- an
Abortflag.
Current environment execution stops later dependency levels whenever any applied result has Abort: true, regardless of the descriptor's AbortPolicy. Sibling sources in the same level have already run and their results are still applied in registration order. Subject results have no Abort field, so the descriptor policy is entirely ineffective for subject sources.
Authentication backends
The base Backend interface owns two operations:
VerifyPasswordreturnsBackendResult;ListAccountsreturnsAccountListResult.
A backend can additionally implement any independent typed MFA interface:
| Interface | Operations |
|---|---|
TOTPBackend | begin, finish, verify, and delete TOTP state |
RecoveryCodeBackend | generate, consume, and delete recovery codes |
WebAuthnBackend | list, save, update, and delete WebAuthn credentials |
PublicMFAStateBackend | return public MFA metadata for an identity edge |
Runtime.NoAuth marks a passwordless identity lookup path. A backend should return safe account, attribute, and identity metadata without asking the credential provider for a password.
BackendIdentityResult carries field-name metadata and group values. TOTPSecretField and TOTPRecoveryField name attributes; they do not contain a secret or recovery codes. Typed MFA operations are authoritative for plugin-owned MFA state.
Backend errors and panics become secret-safe temporary failures at the host boundary. Return domain results such as UserFound: false or Authenticated: false for legitimate outcomes; do not use raw database errors as decision signals.
Subject sources
A SubjectSource runs after backend evaluation and receives a copied BackendResult. It may return:
- status, logs, facts, and runtime changes;
- a selected backend reference;
- backend string-attribute mutations;
- an explicit
BackendResultPatchfor account, account field, auth flags, selected backend, and attributes; - a bounded HTTP response mutation;
Rejected.
The input backend result is deliberately not a reversible serialization of all internal core state. Reverse subject readback does not reconstruct the prior backend Status. BackendResult.Facts is instead a projection of completed public Lua and native plugin provider output from earlier checkpoints of the same Decision Service session, for example GeoIP facts; caller, token, transport, and host-only facts are excluded, and a provider's previous output is dropped before it is scheduled again. Identity field metadata and groups can be read, but BackendResultPatch cannot replace identity metadata, groups, status, facts, or MFA values.
Parallel sibling sources receive independent copies. Mutating an input map does not mutate the retained result or another sibling's request.
Response mutations
Subject sources and synchronous obligations can return allowed header changes while an HTTP response is still mutable:
Response: pluginapi.ResponseMutation{
Headers: pluginapi.ResponseHeaderMutation{
Delete: []string{"X-Old-Risk"},
Set: map[string][]string{
"X-Nauthilus-Risk": {"stepup"},
},
},
StatusHeader: true,
}
The host canonicalizes names, applies deletes before sets, and merges results in deterministic execution order. It filters forbidden or host-owned names including credentials, cookies, hop-by-hop fields, and Auth-Status. StatusHeader asks the host to materialize the selected status through the supported response path; it is not a general arbitrary-header bypass.
Response mutation has no body, cookie, stream, writer, Gin context, or arbitrary status-code surface. Use a native hook when the plugin owns a complete HTTP response. Mutations are no-ops for non-HTTP or already-written responses.
Synchronous obligations
An ObligationTarget executes on the request path after policy selection. It receives:
- the request snapshot and current runtime;
- a strict read-only
ArgsViewcontaining policy-selected effect arguments; - the current validated fact set.
Its ObligationResult may report application state, temporary failure, status/logs/facts, runtime changes, and response headers. Keep it deterministic, quick, and bounded. Result facts must be registered for the auth_decision stage. StatusMessage.Temporary is not the obligation tempfail switch; use ObligationResult.Temporary.
The request also exposes ExecutionIdentity() and the host-captured BackendOutcome credential evidence. Because an obligation runs before the response, a synchronous obligation is the right place for work whose failure must turn into a Policy tempfail. See host-captured request views.
Detached post-actions
A PostActionTarget accepts or skips work after policy selection. It receives effect args and facts, an optional credential provider, the host-captured BackendOutcome, its ExecutionIdentity(), and, only with the password_hash capability, the host-generated canonical password hash when a password existed.
Native and Lua post-actions are executed as one detached plan in final-obligation order. Of the current PostActionEnqueueResult fields, only RuntimeDelta is applied by the host; Status, Logs, QueuedID, Enqueued, and Temporary are currently ignored. A returned error or an invalid delta aborts the remaining mixed plan.
The delta is visible only to later steps in that plan. It does not change:
- the already selected policy decision;
- the client response;
- response mutations;
- live request runtime after the plan finishes.
Use Host.Go for supervised background work and return promptly after acceptance. Post-action results do not add new facts to the completed decision.
PostActionRequest.PasswordHash contains the host-owned lowercase 64-character SHA-256 digest of nonce + NUL + password, but only when the module requested and the operator allowed the password_hash capability. Without that grant the field is empty; credentials does not imply it. Development mode does not change this representation. Treat it as sensitive password-derived material and never log or persist it. The bundled ClickHouse integrations validate the full digest and export only its first eight characters to their established analytics schema, and therefore require allow_capabilities: [password_hash].
Callback admission limits
Obligation and post-action targets may implement AdmissionLimits() (requestsPerSecond, maxConcurrency int). The registrar freezes the values once (1 to 10000 requests per second, 1 to 1024 concurrent callbacks; invalid values reject registration), and the host enforces them per registered component and runtime generation before invoking plugin code. The rate limiter is a token bucket with burst equal to the rate. Overload returns a temporary callback failure; the rate, concurrency, or cancellation cause appears in bounded operational diagnostics without request payloads.
Ordering can be semantically important. The bundled HIBP action must precede ClickHouse when the ClickHouse row should include the fresh plugin.exchange.haveibeenpwnd.hash_info value.
HTTP hooks
A Hook declares a method/path binding and returns a complete HookResponse. The host provides copied path, method, query, redacted headers, bounded body, and snapshot data.
Without an operator scope override, current coarse authentication behavior is:
| Auth | Public scope | Internal scope | Admin scope |
|---|---|---|---|
none | allowed | 403 | bearer token with nauthilus:admin |
token | valid bearer token | bearer token with nauthilus:authenticate | bearer token with nauthilus:admin |
admin | bearer token with nauthilus:admin | bearer token with nauthilus:admin | bearer token with nauthilus:admin |
session | Basic-auth context | Basic-auth context | bearer token with nauthilus:admin |
Every administrative descriptor, meaning HookAuthAdmin or HookScopeAdmin with any auth mode, always requires a bearer token with the backchannel nauthilus:admin scope. A Basic/session context never satisfies an administrative hook.
Operators can configure plugins.modules[].hooks[].required_scopes for a
registered local hook. A non-empty list is injected into the effective
descriptor and requires HookAuthToken on a non-public hook. For a
non-administrative hook it replaces the coarse matrix above with any-of
semantics: one matching bearer scope is enough. For an administrative hook the
list can only narrow access: the caller needs nauthilus:admin and, in
addition, at least one listed scope. Plugins must leave HookDescriptor.RequiredScopes empty. Authorization
runs before body reading and request construction; invalid, duplicate,
unmatched, or conflicting operator entries fail registration.
Hooks cannot return unsafe response headers. The host owns content length, transport/security headers, authorization/cookie headers, and HEAD body suppression. Register separate GET and HEAD descriptors when both methods are intended.
Canonical and alias bindings are method-specific. Aliases are tried only after normal route matching fails, so an application route takes precedence. The current collision boundary removes both duplicate canonical bindings or the duplicate alias from routing instead of failing startup.
The request body is read only after authorization. A positive MaxBodyBytes is
the exact limit; zero uses the 1 MiB host default. Oversized requests return 413
without invoking plugin code. There is currently no corresponding hook response
body limit, so plugins must bound generated responses themselves.
Hook errors and recovered panics produce a secret-safe 500 response, descriptor deadline expiry produces 504, and an invalid status or unsafe response header produces 502. Returned error details and recovered panic values are not exposed to the HTTP caller.