9.2. Conditional access policies

A conditional access policy counts authentication log entries for one subject over a time window and runs actions when a threshold is reached. These policies are managed under Policies → Conditional Access and are independent of the policies described in Policies.

9.2.1. Policy settings

name

A unique name for the policy. It identifies the policy in the authentication log and in the notification emails.

priority

A unique positive number up to 1000000; a lower number takes precedence. This only decides an outcome for the pre-auth DENY question (see How a request is evaluated): policies are consulted in ascending priority order and the first one that denies a request wins, so no lower-priority policy is even evaluated. Every other action - LOCK_USER, BLOCK_IP, the email actions - runs for every enabled, matching policy regardless of priority; there, priority only decides whose error message stands when two policies write the same lock or block, see When a change to the wording takes effect. Use Reorder Priorities in the policy list to change the order, then Save Order.

enabled

A disabled policy is not evaluated at all.

dry run

Evaluate the policy but enforce nothing, see Trying a policy out first.

target

What the policy counts and acts on:

  • user - the authenticating user, identified by resolver, user ID and realm. A local administrator (an account created with pi-manage admin add) is counted and locked by these policies too, identified by the login name, see Local administrators. Requests that identify neither a resolvable user nor a local administrator are ignored by these policies.

  • source_ip - the client address. These policies also apply when no user could be resolved, which is what makes spraying and enumeration visible.

The target restricts the available count modes and actions.

tracked events

The authentication event types this policy counts, see Event types. Several types can be selected; the policy counts their sum, so a policy tracking three failure types trips on their combined total.

The event types conditional access writes for its own rejections cannot be tracked. Otherwise a lock would keep refreshing itself on the very requests it refuses, and never expire. SUSPENDED_API_KEY_USED cannot be tracked either: it describes the client a request arrived with rather than how an authentication ended, and can only be filtered for in the Authentication Log.

time window

How far back the count reaches. Entries older than the window do not count, so an idle account recovers on its own.

count mode

What a single count represents:

  • PER_REQUEST - one authentication log entry. Default for user.

  • PER_ATTEMPT - one authentication attempt. A challenge-response login spans several requests; they count once. Use this if a multi-challenge login should not count as several events.

  • DISTINCT_USERS - the number of different accounts the address targeted, not the number of requests. Available for source_ip only, and its default. This is the password spraying and user enumeration signal: attempted user names are counted, so guesses at accounts that do not exist count too.

For a source_ip target the two volume modes amount to plain rate limiting per address.

reset the count on a successful login

Whether a completed login clears what has been counted so far, see Counting and resetting. On by default, and available for a user target only.

conditions

Restrict which requests the policy applies to at all. Without conditions it applies to every request. Available conditions:

  • USER_REALM - the realm of the authenticating user.

  • USER_ROLE - user, admin-internal or admin-external.

  • ENDPOINT - the endpoint the request authenticated against, see Which endpoint served the request.

Each condition is either is one of or is not one of a list of values. Several conditions are combined with AND. Conditions also narrow what is counted, not just whether the policy applies. Pre-authentication they read what the request claims - see Exempting a subject.

Note

A request that carries no value for a condition does not match is one of, but does match is not one of. An exception written as realm is not one of [sales] therefore also covers requests with no realm at all. For USER_REALM this happens when the client does not send a realm and no default realm is defined; when a request names no user - a serial of a token without owner, or the anonymous start of a passkey login - and the client sends no realm, since the default realm is only filled in for a named user; and always for an internal administrator, who has no realm to carry regardless of that setting.

9.2.2. Counting and resetting

With reset the count on a successful login - the default for a user policy - the policy counts the failures since the user’s last successful login, so a legitimate user is not locked by failures from days ago. Every threshold of the policy counts that way, the DENY decision included, so a denial also lifts on a successful login and not only as the window drains.

Turn it off to make a threshold mean this many entries in the window outright, whatever happened in between. That is what a rate limit wants: the shipped rate limit templates cap how many attempts an account may make per window, and one of those attempts succeeding must not hand the next burst a fresh budget.

A source_ip policy never resets on a success. One user authenticating successfully must not clear a signal that is aggregated over everybody sharing that address. The setting is therefore unavailable for that target, and a policy that asks for it is rejected rather than quietly ignored.

9.2.3. Stages and thresholds

A policy has one or more stages. Each stage has a failure threshold and a list of actions, and may be given a name for the log.

Only the stage with the highest matching threshold runs, and only its actions. This is how an escalation is expressed: lock for ten minutes at 5 failures, lock permanently at 20. Thresholds must be unique within a policy.

By default an action fires once, exactly when the count reaches the threshold: an email configured at 8 is sent on the 8th failure and not again on the 9th. It also fires if a single evaluation’s own request is what carried the count from below the threshold to at or above it, even when that step skipped the threshold value itself - e.g. one of two concurrent failed logins, each committing before the other is counted. A narrower race, where several such requests all commit before any of them is evaluated, can still let this particular crossing go unfired; the count keeps climbing regardless, so later requests remain subject to whatever higher stage the policy defines next. Enable Re-trigger while above the threshold for an action that should fire on every further request instead, for as long as the count stays in the range its stage owns - at or above its own threshold, below the next stage’s. Each stage therefore owns one range of counts, and only the stage owning the current count acts, so escalation is a hand-over rather than an overlay.

This is a live read of the count, not a state the policy remembers: should the count later drop back into a milder stage’s range - the time window aging old failures out, or a successful login where reset_on_success applies - that stage’s re-triggering action fires again. DENY defaults to re-trigger, as it is a one-time action denying only the current request, and follows the same rule. In practice, though, a re-triggering DENY rarely hands over to a higher stage on its own: once enforced, every further request is refused before any credentials are checked and logged as ACCESS_DENIED, a type no policy can count, so the very count that would carry it past the next threshold stops climbing while the refusal holds.

It is worth giving the highest stage’s restricting action - its lock, block or DENY - Re-trigger while above the threshold. Because a fire-once action only fires on the evaluation that carries the count from below the threshold to at or above it, a subject whose count is already past it before that evaluation stays unrestricted: failures that predate the policy, or an administrator who lifted a lock while the failures behind it were still inside the window. The highest stage owns every count from its threshold upwards, so re-triggering there restricts on the next request whatever put the count up there. The shipped templates are written this way.

Stages are evaluated from the highest threshold down, so the order follows the thresholds themselves and there is nothing else to configure.

A threshold counts failures and therefore starts at 1. DENY is the exception: it states a standing verdict instead of reacting to a count, so a stage carrying nothing but DENY may use threshold 0, which then means always. That is how a lockdown is written - refuse everything the policy covers, whatever the subject has done. Where the policy has further stages, that 0 stage hands over like any other, so always reaches up to the next threshold.

Warning

A DENY at threshold 0 refuses every request the policy covers, whatever the subject has done. Scope it with conditions, and leave yourself a way back in. Either kind can shut you out: a user policy reaches a local administrator like anybody else (see Local administrators), and a source_ip policy applies to whoever is behind the address. Against a source_ip policy, exempt your own address in PI_CONDITIONAL_ACCESS_NEVER_BLOCK: such a policy never blocks or denies it (see Never blocking an address). Against a user policy the address does not help - its DENY and its locks apply from any address - so write user role is not one of [admin-internal] there and read what that exemption costs in Exempting a subject. A DENY stores no state, so none of the pi-manage conditionalaccess reset commands can lift it; undoing an unscoped one means disabling the policy itself, with pi-manage conditionalaccess disable-policy <name> if it has locked you out of the WebUI, see Managing policies on the command line.

Each stage also has an optional error message, the text an end user sees on a request that a lock, block or DENY from that stage turns away - never on the request that trips the stage, which is answered on its own merits. It is empty by default: the rejection then names neither the restriction nor its duration and carries at most the generic Authentication failed.. It is identical to any other failed authentication only if hide_specific_error_message is set as well, see How a request is evaluated and Interaction with the masking policies.

9.2.4. Actions

LOCK_USER, BLOCK_IP

Lock the user, or block the source address, for the configured duration. The restriction lifts itself when the duration has passed. The duration is required: a LOCK_USER or BLOCK_IP action without a positive duration in seconds is refused when the policy is saved.

PERMANENT_LOCK_USER, PERMANENT_BLOCK_IP

The same, without an expiry. Only an administrator can lift these.

DENY

Refuse this single request pre-authentication, without storing anything. The rejection lifts by itself as the counted entries age out of the window - and, on a policy that resets on success, on the next successful login. Use it for a rate limit that must not leave a lock behind.

EMAIL_USER, EMAIL_ADMIN

Notify the user, or an administrator, that the threshold was reached. EMAIL_USER sends to the address in the user store. EMAIL_ADMIN sends to a list of addresses or to the internal administrators.

Neither turns a request away, so neither is ever reported in a response: a stage carrying nothing but these is silent whatever error message is written on it, see Error messages.

An email action needs the identifier of an SMTP server configuration configuration plus subject and body. Subject and body may contain {username}, {realm}, {resolver}, {client_ip}, {count}, {threshold}, {stage_id}, {event_type}, {policy}, {time}, {email}, {givenname} and {surname}. The last three come from the resolver and are only filled in when a user was resolved for the request - which EMAIL_ADMIN on a source_ip policy is not guaranteed to have, since that target also applies where no user could be resolved at all.

Which actions a policy may use depends on its target:

Target

Actions

Count modes

user

LOCK_USER, PERMANENT_LOCK_USER, EMAIL_USER, EMAIL_ADMIN, DENY

PER_REQUEST, PER_ATTEMPT

source_ip

BLOCK_IP, PERMANENT_BLOCK_IP, EMAIL_ADMIN, DENY

DISTINCT_USERS, PER_REQUEST, PER_ATTEMPT

Note

BLOCK_IP in a user policy is not available: a user policy knows nothing about how many accounts an address attacked. Use a source_ip policy with DISTINCT_USERS for that.

9.2.5. Exempting a subject

An exception is written as a condition on the policy you want the subject exempted from - realm is not one of [service], or user role is not one of [admin-internal]. A policy whose conditions do not match is never evaluated for that request at all, so the exemption is exact and visible on the rule it applies to.

Because the condition takes the whole policy out of play, it covers every action the policy carries. The exempt subject is neither refused, nor locked, nor blocked, nor mailed about.

An exemption for a service account or a monitoring probe therefore goes on each policy it needs to be out of - which is also where an administrator reading that policy will look for it.

Warning

An exemption written as user role is not one of [admin-internal] also exempts everyone who merely claims to be an internal administrator. The role is read from the login name before any password is checked, so a login naming a local administrator is exempt whoever sent it, and the attempts made under that name are neither refused nor counted by the policy - unless a user of that name exists, in which case they are counted against that user, whom they can lock out (see Local administrators). The most guessable account in the installation is then the one account the policy does not protect. Write the exemption only on the policies that need it. On a source_ip policy, where an address will do, exempt the address in PI_CONDITIONAL_ACCESS_NEVER_BLOCK (see Never blocking an address) instead: an address is a fact of the connection rather than a claim of the request. A user policy does not look at the address - its DENY and its locks apply to requests from an exempt address as well - so there the address is no substitute for the condition.

9.2.6. Templates

The New Conditional Access page offers templates for the common cases - password brute force, MFA brute force, per-user and per-IP rate limits, password spraying and user enumeration. A template fills in tracked events, window, count mode, stages, actions and whether the count resets on a successful login; you pick the priority and review the thresholds. The restricting action of each template’s highest stage re-triggers above its threshold, as recommended above. The two per-IP rate limit templates are pre-set to dry run, because their threshold depends on how many users share an address, see Trying a policy out first.

9.2.7. Trying a policy out first

A policy with dry run enabled is evaluated like any other, but nothing is enforced: no user is locked, no address is blocked, no email is sent and no request is refused. Instead each action that would have run is recorded with the authentication log entry that triggered it, including how long the restriction would have lasted.

This is the way to size a threshold against real traffic. It is particularly recommended for source IP policies, where the right threshold depends on how many users share an address - shared egress such as NAT or CGNAT can put hundreds of users behind one address.

Filter the authentication log on dry run outcomes to see what a policy would have done, then disable dry run once the threshold fits. Dry run can also be switched on and off from the command line, which defuses a policy that has locked everybody out without losing what it records.

Turning dry run off starts a fresh count by default: events that accumulated before are not counted towards the threshold once the policy starts enforcing, so a policy that would have locked someone out several times over during the trial does not lock them out on the very next request just because it is now enforced. Only events from the moment dry run was disabled count towards the threshold, until one full time window has passed and the policy counts its configured window again.

Switching a policy back to dry run keeps that starting point rather than discarding it, so the trial keeps simulating the enforcement it interrupted - asked what the policy would do right now, dry run answers with the same count the policy would really be using.

The reset can be turned off, both in the WebUI (a dialog appears when disabling dry run) and via the API (PATCH /conditionalaccess/policy/(policy_id) with reset_counters_on_enforce=false). The policy then counts its full time window from the first enforced request on - for example when the trial was run specifically to see how many requests would already be caught, and enforcing on the very next matching request is the point.

Warning

Counting events from before enforcement began can leave a policy silent rather than strict. A stage fires as the count reaches its threshold (unless the action sets Re-trigger while above the threshold), so a count that already sits above a threshold never reaches it: that stage stays quiet until the old events age out of the time window and the count climbs through the threshold again. On a busy policy the count may not drop below the threshold at all, and a real attack then raises no alarm. This is why the reset is the default.

9.2.8. Managing policies on the command line

The policies can also be managed with pi-manage, which is what you need when a policy has locked you out of the WebUI itself - an unscoped DENY, say:

pi-manage conditionalaccess list-policies
pi-manage conditionalaccess disable-policy <name>
pi-manage conditionalaccess enable-policy <name>
pi-manage conditionalaccess enable-dry-run <name>
pi-manage conditionalaccess disable-dry-run <name>
pi-manage conditionalaccess delete-policy <name> [--yes]

list-policies prints one line per policy - name, id, enabled and dry-run state, priority and target - lowest priority number first, which is the order the policies are evaluated in.

Every other command addresses one policy, either by the name given as its argument or by --id - which is the handier one for a name that contains spaces:

pi-manage conditionalaccess disable-policy --id 7

disable-policy is the way back in: the policy is no longer evaluated, so it cannot refuse the next request. enable-dry-run is the gentler variant - the policy stays enabled and keeps recording what it would do, but nothing is enforced. Both leave the locks and blocks the policy has already written in force, so clear those as well, see Lifting locks and blocks. delete-policy removes the policy with its stages and actions for good and asks for confirmation first, so pass --yes when calling it from a script.