> For the complete documentation index, see [llms.txt](https://docs.defguard.net/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.defguard.net/2.1/in-depth/architecture-decision-records/2.1.md).

# 2.1

## Group client traffic policies

The client traffic policy decides whether users may route all of their traffic through the VPN, are prevented from doing so, or are forced to do so. In previous Defguard versions this was a single instance-wide setting stored in the enterprise settings. Defguard 2.1 allows administrators to override it for selected groups, while every group without an explicit assignment keeps following the instance-wide policy.

### Requirements

1. Administrators should be able to assign a traffic policy to selected groups, and a group should have at most one assigned policy.
2. A user may belong to several groups, so the effective policy has to be resolved from all applicable assignments.
3. Groups without an assignment must keep using the instance-wide policy.
4. The client configuration and enrollment flows must receive the effective policy for the user, not the raw settings.
5. The feature must be limited to installations with an active Business license, and installations without one must behave exactly as they did before.
6. Losing the license must disable the feature without destroying the assignments the administrator saved.
7. Existing clients that do not know about the new field must keep working.

### Considered options

#### 1. A dedicated assignment table, resolved in Core

* Store one row per assigned group in a new table, with the group id as the primary key.
* Return the assignments as part of the enterprise settings API.
* Resolve the effective policy in Core, while building the client configuration.

**Pros**

* The primary key alone guarantees that a group has at most one policy.
* The core group model stays independent of an optional enterprise feature - a group without an assignment simply has no row.
* Deleting a group removes its assignment through a foreign key cascade.
* Resolution happens in one place, on the side that the client cannot influence.

**Cons**

* Building the client configuration performs an additional lookup for the user's group policies.
* The enterprise settings response grows, and the UI has to combine it with group metadata to show a policy per group.

#### 2. A policy column in the group table

Add the policy directly to the `group` table.

**Deal-breaking issue:** this puts an optional enterprise field into the core group model, which every group row then carries, and it turns "no assignment" into a value in that column instead of the absence of a row.

#### 3. Exposing the assignments through the group API

Return the assigned policy together with the rest of the group information from `/group-info`.

**Deal-breaking issue:** the enterprise settings response already carries the policy-to-group mapping, and `/group-info` is a widely used contract. Coupling it to enterprise policy storage would change a response that many callers depend on, for an unrelated reason.

#### 4. Resolving the effective policy in the client

Send the assignments and the user's groups to the desktop client and let it work out which policy applies.

**Deal-breaking issue:** client behaviour has to be enforced by Core. This would move the decision to the side that the policy is meant to constrain, and make it depend on client-side state.

#### 5. A dedicated endpoint for policy assignments

Manage the assignments through their own API instead of the enterprise settings API.

**Deal-breaking issue:** assignments are edited as part of the client settings form. Keeping them in the same request gives one transactional update and one audit entry for the whole form, which a separate endpoint would have to reproduce.

### Decision

Option 1, the dedicated assignment table with resolution in Core, is selected.

### Rationale

Assignments are optional, sparse and enterprise-only, which is exactly what a separate table expresses well: the absence of a row is the absence of a policy, the primary key enforces the "one policy per group" rule without any application logic, and the core group model does not change at all. The same argument decides the API surface. The mapping is already part of the enterprise settings, so putting it into the settings request keeps validation, persistence and audit for the whole form in one transaction, instead of spreading them across two endpoints that would have to agree with each other.

Resolving the effective policy in Core follows from what the policy is for. A traffic policy constrains what the client may do, so the client cannot be the component that decides which policy applies to it. Doing the resolution while the client configuration is built also means there is a single place where group membership, group assignments, the instance-wide setting and the license state come together, and every configuration fetch reflects the current state of all four.

### Database model changes

The migration adds a `group_client_traffic_policy` table:

* `group_id` is the primary key and references `group(id)` with `ON DELETE CASCADE`.
* `client_traffic_policy` reuses the existing database enum, so a group assignment cannot express anything the instance-wide setting could not.

Existing installations start with no rows, so after the migration every group follows the instance-wide policy and behaviour is unchanged.

### Policy resolution

Resolution takes the instance-wide policy and the policies of all groups the user belongs to, and applies them in a fixed order:

1. **Disable all traffic** wins over everything else.
2. Otherwise **Force all traffic** wins over the instance-wide policy.
3. Otherwise an explicit **No limitation** assignment wins over the instance-wide policy. This is what allows a group to opt out of a restrictive global setting.
4. A user with no applicable assignment gets the instance-wide policy.

Resolution itself is a pure function - loading the assignments and checking the license happen outside it, so the precedence rules can be tested on their own.

The effective policy is calculated while building the client configuration, which the client receives during enrollment and while polling. As a result, a change to the assignments or to a user's group membership takes effect on the next configuration fetch, rather than being pushed to a connected client. Clients that predate the policy field are still served the deprecated `disable_all_traffic` flag, which is now set from the resolved policy and is true only when that policy disables all traffic.

### API changes

The assignments are part of the enterprise settings API:

* `GET` returns them regardless of license state, so a saved configuration is never hidden from the API.
* `PATCH` takes them as an optional field. Including it replaces all assignments in one transaction, omitting it leaves them untouched. This distinction matters for partial updates and for license transitions.
* Before anything is replaced, Core rejects a group that appears under more than one policy, and a group that does not exist. If validation or persistence fails, the transaction rolls back and the previous assignments remain in place.
* Enterprise settings audit events record the complete assignment state before and after the change, not only the instance-wide settings.

### License behaviour

Group policies require an active Business license. Without one:

* resolution ignores the assignments and the instance-wide policy falls back to the unlicensed default;
* the settings page shows no assigned groups, locks the traffic policy to **No limitation** and disables the policy controls;
* **Edit groups** opens the upgrade or expired-license modal instead of the group selection modal;
* the UI stops sending the assignment field, so an unlicensed instance cannot overwrite stored assignments with empty arrays.

Nothing is deleted. When a valid license is restored, the stored assignments are read again and become effective without any reconfiguration. We decided against clearing them on expiry, because license state should disable a feature, not destroy configuration.

## Linux service locations

Service locations are WireGuard connections managed in the background by the client daemon, used to reach services without a user-controlled tunnel. They have been available on Windows since the 1.6 desktop client, in the Pre-logon and Always-on modes. Defguard 2.1 introduces service locations on Linux.

### Requirements

1. Always-on service locations must reconnect after the host or the daemon restarts, without user action and before a desktop session exists.
2. The connection must be managed by the privileged daemon, not by the desktop application or the CLI.
3. Service locations must not be exposed as ordinary user-controlled locations, and must not react to normal connect and disconnect actions.
4. The daemon must have everything it needs to bring a service location up - the configuration and the instance private key - before any user logs in.
5. Configuration changes have to reach the daemon, but unchanged configuration must not disturb working connections.
6. Failed interface setup must not leave a partially configured WireGuard interface behind.
7. Existing Windows behaviour must be preserved, and the shared service location manager should not diverge between platforms more than necessary.

### Considered options

#### 1. Reuse the tunnel manager used for ordinary locations

Treat a service location as another location in the client database and connect it through the same code path as a user location.

**Deal-breaking issue:** ordinary tunnels are owned by the desktop application and follow user actions. Service locations have to survive UI restarts, come up before anyone logs in, and stay out of the normal connect and disconnect controls. Reusing that path would tie background service access to desktop state.

#### 2. Daemon-managed locations with client-side persistence

Keep the lifecycle in the daemon, but store the service location configuration in the client database, the way ordinary locations are stored.

**Deal-breaking issue:** that database belongs to the desktop user and is not available to the daemon early enough. The daemon has to reconnect Always-on locations at startup, and it needs the instance private key to do so.

#### 3. Daemon-managed locations with daemon-owned persistence

* Keep the lifecycle in the daemon and give the daemon its own state directory, readable and writable only by root.
* Use the existing daemon RPC operations for saving and deleting service location configuration on both Windows and Linux.
* Keep platform-specific behaviour in the `ServiceLocationManager` implementations.

**Pros**

* Startup recovery does not depend on the desktop database or on a logged-in session.
* The private key and the interface lifecycle stay on the privileged side of the boundary.
* Windows and Linux share one RPC surface, so the client does not need platform-specific synchronization logic.

**Cons**

* The daemon owns sensitive files and has to maintain their permissions itself.
* Service location state exists both in the client database and in the daemon's directory, and the client is responsible for keeping them in agreement.

### Decision

Option 3, daemon-managed locations with daemon-owned persistence, is selected. On Linux, only Always-on service locations are supported.

### Rationale

One requirement decides the design: an Always-on location has to be up before anyone logs in. That rules out any state the daemon cannot read on its own, and it also decides where the instance private key lives, because whoever brings the interface up needs that key. Once the daemon owns the key and the interface, it may as well own the configuration that describes them, and the desktop application is left with the job it can actually do - noticing that the configuration changed and telling the daemon about it.

Pre-logon is a different matter. It is built on the Windows service lifecycle, and there is no Linux equivalent that behaves the same way. We preferred to leave the mode Windows-only and filter it out on Linux, rather than ship something that looks like Pre-logon but has different guarantees. Unsupported modes are therefore removed before the daemon persists anything or brings up an interface.

### Platform and version gating

Because the feature depends on both the platform and the client version, Core advertises it through per-platform rules and filters service location configuration out of a device configuration response when the client cannot use it. Service locations are supported on Windows from client version 1.6.0, and on Linux from 2.1.0. Older Linux clients keep working unchanged, because they never receive the configuration in the first place.

### Daemon-owned state

The daemon keeps one file per enrolled Defguard instance:

```
/etc/defguard/service_locations/<instance_id>.json
```

Each file holds the instance identifier, the instance WireGuard private key used by its service location interfaces, and the supported service location definitions. The directory is created with mode `0700` and the files with mode `0600`, and private keys are kept out of debug output.

Saving writes the complete current set of Always-on locations for that instance. An empty set is stored as an empty list, while deleting an instance's service locations removes the file entirely. Every operation is scoped to a single instance, so a configuration change for one enrolled Defguard instance cannot disturb the service locations of another.

At startup the daemon loads every persisted file and connects the locations it finds. Already-connected locations are skipped, which makes the attempt idempotent and lets the daemon simply repeat it a bounded number of times when the network, WireGuard or DNS is not ready yet. The retry policy belongs to the daemon and is passed into the shared auto-connect task, rather than being fixed inside the service location manager.

### Configuration synchronization

The client pushes service location state to the daemon only after a configuration change has actually been applied, not on every configuration poll. Saving reconciles the instance: removed locations are disconnected, and the remaining ones are reset by disconnecting the existing interface and reconnecting it with the new configuration. Calling that on every poll would repeatedly tear down healthy connections, so a poll that changes nothing calls nothing. All resets are attempted before an aggregate error is returned, so one failing location does not leave the remaining ones unreconciled. Enrollment and manual instance updates use the same path, so the daemon does not have to wait for the next poll to become correct.

### WireGuard interface handling

Linux WireGuard interfaces are named dynamically as `wgN`, and the helper that produces a name returns the next free one. This makes the name useless as an identity for a service location: recomputing it on disconnect can return a different free name, leave the old interface in place, and create a duplicate interface on the next reconnect.

The daemon therefore identifies a managed interface by matching the service location peer public key against the peer data of the interfaces it holds. The key is already part of the service location contract, and an interface configured for a service location has precisely that location as its peer.

We considered two alternatives and chose neither. Deterministic interface names would remove the lookup, but Linux interface names have a strict length limit, so this would require a naming scheme, collision handling and a migration for existing installations. Storing the interface name in the manager's entry for a connected location would add another piece of platform-specific runtime state, while the peer public key is a more meaningful identity than an ephemeral interface name.

The lookup is deliberately limited to the interfaces tracked by the running daemon, and it returns one match. Several interfaces sharing one service location peer indicate an unhealthy state rather than normal operation, so the daemon returns the matching interface instead of treating the situation as a general cleanup task for interfaces left behind by other processes or by manual administration.

If configuring the interface, the routing or DNS fails after the interface has already been created, the daemon removes the interface before returning the error, so a failed attempt cannot leave an unusable interface that would interfere with later retries. A missing runtime interface during disconnect is treated as an idempotent cleanup condition and logged, rather than being an error.

### Consequences

DNS configuration is part of interface setup, so a host whose resolver backend cannot accept the requested configuration fails the whole setup. On systemd-based distributions this means `systemd-resolved` has to be installed and active when the WireGuard library uses that backend.

Startup recovery is bounded. A location that is still unreachable after the last attempt stays down until the daemon restarts or another configuration change triggers reconciliation. Reconciliation itself resets active interfaces when their configuration changes, so a configuration update is not transparent to an established service location connection.

## Directory synchronization scope

In previous Defguard versions, directory synchronization covered every user the configured application could see in the provider's directory. In a large directory this means creating and updating accounts that have no reason to exist in Defguard at all. A field restricting synchronization to selected groups first appeared for Microsoft Entra ID only. Defguard 2.1 makes it part of the common directory synchronization configuration, so it works the same way for Microsoft Entra ID, Google Workspace, Okta and JumpCloud.

An administrator lists one or more directory groups, and only their members are then considered. Defguard fetches the listed groups, resolves their members, and applies the resulting list both to regular state synchronization and to the import that creates accounts ahead of the first login. An empty list means no restriction, which keeps existing configurations behaving as they did.

Two properties of the filter are deliberate. A group that Defguard cannot see in the directory is skipped with a warning in the Core log instead of failing the whole synchronization run, because one renamed or misspelled group should not stop everybody else from being synchronized. And the filter decides who has a Defguard account, not what that account may do - group membership mapping and access rights are configured separately, so restricting the scope of synchronization is not a substitute for access control.

## Device Posture Checks

Device posture is enforced as runtime admission to a VPN location. Clients report platform signals, Core evaluates reusable location policies, and the gateway normally admits protected peers only when backed by an active VPN session with a fresh runtime key.

### Context

Access policy must be able to require properties of the connecting device, such as:

* Minimum software versions
* Disk encryption
* Malware protection
* Operating-system integrity
* Domain membership
* Recent security updates

These properties differ by operating system and may be unavailable because the platform does not support them, the client lacks permission, or detection failed.

A local client decision is not sufficient enforcement. A client can create a WireGuard interface even when policy rejects it. Enforcement must happen where gateway peer membership is controlled.

The design must also:

* Compose posture with location MFA
* Support posture without MFA
* Remain compatible with older clients
* Support headless service locations

### Decision

Device posture is represented by reusable policies assigned many-to-many to VPN locations.

The connecting client collects raw posture signals and sends them to Core through the proxy. Core is the sole policy evaluator. Every policy assigned to the location must pass.

A successful evaluation does not permanently change device or location configuration. It authorizes a VPN client session and produces a fresh runtime WireGuard preshared key.

In normal operation, the gateway admits posture-gated peers only through active sessions. Static peer configuration is withheld.

Posture is evaluated:

* Before MFA when posture and MFA are both required
* Through a dedicated posture-only authorization path when MFA is disabled

A definitive rejection:

* Revokes active sessions for the device and location
* Removes the runtime gateway peer
* Causes service-location clients to tear down an existing local tunnel

### End-to-End Flow

1. An administrator defines a posture policy with allowed operating-system rules and assigns it to locations.
2. Core marks compatible client configurations as posture-required and withholds static gateway peers.
3. The client collects platform signals and starts posture-only authorization or MFA with posture.
4. Core authenticates access, evaluates every assigned policy, and accumulates all failures.
5. On success, Core persists a VPN session, authorizes the gateway peer with a runtime key, and returns that key.
6. On rejection, Core disconnects active sessions and deauthorizes the gateway peer.

### Policy Model

#### Reusable Policies

A posture policy contains:

* Name and description
* Desktop minimum client version
* Mobile minimum client version
* Prerelease-version allowance
* At most one rule for each supported operating system

Policies and locations have a many-to-many relationship. Assignment from either side replaces the complete assignment set. Deleting a policy removes its assignments.

#### Composition

Multiple policies assigned to one location use AND semantics.

Core evaluates all assigned policies and returns accumulated failure reasons. A device must also match an operating-system rule. The absence of a rule for the reported operating system means that operating system is not allowed.

#### Supported Rules

* **Windows:** OS major version, disk encryption, antivirus, Active Directory membership, update age
* **macOS:** OS major version, disk encryption, device integrity
* **Linux:** Kernel major version, disk encryption
* **Android:** OS major version, device integrity, security-patch age
* **iOS:** OS major version

Core supports all five report types. The desktop client implements Windows, macOS, and Linux collection. Mobile collection belongs to the mobile clients.

#### Comparison Rules

OS and kernel requirements compare major versions.

Client requirements compare normalized semantic versions with a separate prerelease rule.

Update-age values fail only when greater than the configured maximum. Android patch dates use `YYYY-MM-DD`.

#### Unavailable Data

Signals distinguish values from:

* `NotApplicable`
* Insufficient permissions
* Detection failure

Missing, malformed, denied, or failed required data rejects access.

`NotApplicable` means that the check is irrelevant on that client and is skipped.

#### Administration and Licensing

Administrative mutation requires:

* Administrator privileges
* The Device Posture license feature

The web UI supports:

* Creating policies
* Editing policies
* Duplicating policies
* Deleting policies
* Assigning policies to locations
* Assigning locations to policies

Assignment changes immediately refresh affected gateway peer sets.

### Authorization Paths

#### Posture-Only Location

Interactive clients and service daemons call:

```
POST /api/v1/posture/connect
```

The request is sent through the configured proxy URL and contains:

* Core location ID
* Device WireGuard public key
* Enrollment polling token
* Raw posture report

Core validates the token before revealing location or device information. It binds the token to the claimed device and verifies:

* The user is active
* The user can access the location
* The device is assigned to the location

On success, Core creates a VPN client session without an MFA method.

#### MFA With Posture

Posture data is embedded in the MFA start request and evaluated before an MFA challenge begins.

A posture pass alone creates no VPN session or gateway authorization. The VPN session and runtime key are created only after the selected MFA method succeeds.

A posture rejection revokes any prior active session for the device and location before returning the MFA failure.

#### Service Locations

The privileged desktop daemon owns posture authorization for:

* Windows Always-on service locations
* Windows PreLogon service locations
* Linux Always-on service locations

Service-location mode remains mutually exclusive with location MFA.

The service-location reconciler runs every 30 seconds. On Windows, network, logon, and resume events may wake it earlier.

The reconciler requests authorization for:

* Disconnected posture-gated locations
* Connected sessions that appear stale

A connected session becomes stale after more than 180 seconds without a recent WireGuard handshake.

When no handshake exists, the reconciler uses the session authorization time. This prevents a newly created tunnel from being considered stale before its first handshake.

Healthy connected sessions make no posture request.

Stale sessions are reauthorized and their runtime preshared keys are updated in place.

#### Policy-Removal Recovery

A client may still believe posture is required after all posture assignments have been removed.

In that case, an authenticated posture request is approved with an empty key. Core creates no session and emits no gateway authorization.

The client interprets the empty key as permission to reconnect without a posture preshared key.

### Enforcement and Lifetime

#### Gateway Admission

Locations protected by posture or MFA do not publish ordinary static peers.

Core sends a peer to the gateway only for an active `new` or `connected` VPN client session carrying a runtime key.

Gateway deauthorization removes that peer.

#### Session Lifetime

Core has no independent posture-attestation TTL or periodic posture reevaluation.

Sessions expire through the location’s peer-disconnect threshold when:

* A new session never completes a handshake
* A connected session becomes idle

A new authorization supersedes prior active sessions for the same device and location.

Service daemons independently use a 180-second handshake or authorization-age threshold to renew stale runtime keys before Core’s default 300-second disconnect threshold.

Healthy service sessions are not periodically reauthorized.

#### Configuration Changes

Adding or removing posture assignments rebuilds affected gateway peer sets.

Editing policy contents does not immediately revoke or reevaluate existing sessions. Updated rules apply during the next authorization attempt.

#### Audit Behavior

Policy lifecycle and assignment changes emit administrative events.

Every posture pass or rejection emits a posture activity event.

A rejection also emits a disconnect event for every previously connected session. The disconnect event preserves the source request IP.

Audit delivery failure is logged but does not change the authorization decision.

Persisted activity metadata avoids storing the complete raw posture report.

#### Transaction Boundary

Session persistence and gateway updates are separate operations.

Posture-only authorization commits the session before publishing gateway authorization. MFA authorization currently publishes the gateway update before committing its session.

On posture rejection, Core:

1. Marks all active sessions for the device and location as disconnected.
2. Commits the database transaction.
3. Best-effort publishes gateway deauthorization.
4. Best-effort emits disconnect audit events.
5. Returns the rejection through the proxy.

Gateway or audit delivery failure does not restore revoked sessions or suppress the rejection.

Session persistence and gateway updates are not atomic, so a failure between these operations can temporarily leave only one side updated.

#### Service Rejection and Retry

A recognized HTTP `403` response is a definitive posture rejection.

For a definitive rejection:

* A connected service tunnel is removed locally.
* An already-disconnected service location remains down.
* Core revokes active sessions and deauthorizes the gateway peer.

The following are treated as transient failures:

* Transport failures
* Server errors
* Malformed responses
* Missing polling tokens
* Other authorization errors

For transient failures:

* An existing tunnel remains connected.
* A disconnected tunnel remains down.
* Authorization is retried during a later reconciliation pass.

Linux and Windows remove in-memory interface and location tracking only after operating-system interface removal succeeds.

If interface removal fails, the tunnel remains tracked so reconciliation can retry instead of forgetting an interface that may still be active.

### Signal Collection and Trust

#### Desktop Collection

Windows collects the following through the privileged daemon:

* BitLocker status
* Antivirus state
* Active Directory membership
* Update state

Linux inspects the backing storage stack:

* The client database for interactive checks
* `/` for service-location checks

macOS queries:

* FileVault
* System Integrity Protection

Common signals include client version and operating-system identity.

#### Trust Boundary

Posture reports are claims produced by enrolled client software. They are not hardware-backed remote attestation.

Core validates identity, access, report shape, and policy, but cannot prove that a compromised client truthfully reported local state.

The feature raises the access bar but does not establish device integrity against a hostile endpoint owner.

#### Privacy Boundary

Raw posture data is:

* Sent to the proxy and Core during authorization
* Included in internal evaluation events
* Currently written to Core debug logs

The persisted activity log records the outcome and device identity, not the complete raw report.

Operational log retention remains a separate privacy boundary.

Runtime session keys are generated for each authorization.

#### Transport Compatibility

Enrollment and polling omit posture-gated configurations for clients that predate posture support.

Minimum supported versions are:

* Desktop: `2.1.0`
* Mobile: `1.7.0`

Missing or invalid client versions do not qualify.

This prevents older clients from receiving configurations they could attempt to connect without authorizing.

#### Transport Security

Posture-only authorization sends the polling token and posture report in the request body.

The posture layer uses the configured proxy URL and does not independently require HTTPS. Confidentiality therefore depends on deployment configuration.

### Consequences

#### Benefits

* Posture policy is centralized and normally enforced through gateway admission.
* The same model supports MFA, posture-only access, and service locations.
* Required unavailable evidence fails closed instead of silently weakening policy.
* Runtime keys and inactivity expiry limit authorization lifetime.
* Definitive rejection revokes active sessions and gateway authorization.
* Service clients distinguish definitive rejection from transient infrastructure failure.
* Compatibility filtering prevents older clients from bypassing an unknown requirement.

#### Costs and Current Limits

* Client-reported posture is not hardware-backed attestation.
* Authorization adds a network dependency and can block an otherwise valid connection.
* Existing sessions are not immediately reevaluated when policy contents change.
* Runtime license lapse passes evaluation instead of enforcing configured checks.
* Gateway assignment lookup failure currently disables session-required admission.
* Session persistence and gateway updates are not atomic.
* Some checks require elevated collection and platform-specific implementations.
* The posture transport does not enforce HTTPS independently of proxy configuration.
* Administrative policy and assignment updates are not one atomic API operation.

## SMTP with OAuth2

{% hint style="info" %}
This record is being prepared and will be published here.
{% endhint %}

## Disabling password management

A Defguard user can be sourced from an external identity provider, either LDAP/Active Directory or an external OIDC provider. In that arrangement the external system is meant to own authentication, but in previous versions such a user could still set, change or reset a local Defguard password: through their own profile, through an administrator acting on their account, during enrollment, and through the password reset flow. Defguard 2.1 adds a **Disable password management** setting to each external provider configuration and enforces it on every one of those surfaces.

The setting belongs to the provider configuration (LDAP/AD and External OIDC) rather than being one instance-wide switch.

### Requirements

1. The setting has to be per provider, so an installation that sources users from both LDAP and external OIDC can disable password management for one and not the other.
2. Enforcement must cover every surface that can set a local password: self-service change, administrator-initiated change and reset, enrollment activation, and the password reset flow.
3. Enforcement must happen in Core, not only by hiding controls in the interface.
4. An administrator must never be locked out, including when the external provider is unavailable.
5. Enabling the setting must not strand users who already hold a local password.
6. The password reset flow must not become a user-enumeration oracle, and a blocked user should still learn why nothing happened.
7. Clients that predate the feature must keep working, and must default to the safe behaviour.
8. Turning the setting off must restore the previous behaviour without any reconfiguration.

### Considered options

**1. One computed predicate over the user and the provider configuration**

* Decide with a single pure function taking the user, whether that user is an administrator, the LDAP setting and the OIDC provider flag.
* Expose the result as a computed field on the shared user representation, so every interface surface reads the same answer Core enforces.
* Call the same function in each password endpoint, in enrollment activation, and in the password reset flow.

**Pros**

* There is one definition of "password management is disabled for this user", so the interface, the API, enrollment and password reset cannot drift apart.
* The administrator exemption and the local-password exemption are written once instead of at each call site.
* The function is pure, since the caller supplies the administrator check and the provider state, which makes the whole truth table unit-testable without a database.
* No schema change to the user model is needed.

**Cons**

* Each call site has to load the settings and the configured provider, which needs care to avoid a per-user query when rendering a list of users.
* Whether a user is externally managed is inferred from existing fields rather than recorded explicitly.

**2. Record an explicit user-origin field and gate on that**

Add a field describing where each account came from, and disable password management based on it.

**Deal-breaking issue:** the migration would have to backfill every existing user, and the only information available for that backfill is the same set of signals the predicate already reads. This does not remove the inference, it freezes one moment's inference into the schema. The predicate is compatible with such a field being added later, so nothing is foreclosed by not doing it now.

**3. A single instance-wide switch**

Store one flag in the global settings and apply it to all externally sourced users.

**Deal-breaking issue:** an installation can source users from LDAP and from external OIDC simultaneously, and the two are configured and administered separately. One switch forces the same policy on both, and offers no way to express the common case of wanting it for one of them.

**4. Hide the controls in the web interface only**

Treat the setting as a presentation concern.

**Deal-breaking issue:** the endpoints remain reachable, so the restriction would be bypassable by calling the API directly. Hiding the controls is worth doing, but as an addition to enforcement rather than instead of it.

**5. Enforce the enrollment and reset cases in the proxy**

Let the proxy reject password-bearing requests for externally-managed users.

**Deal-breaking issue:** the proxy has no database access and no authorization role - it forwards requests to Core. It cannot evaluate the condition, and putting the decision there would mean maintaining a second, weaker copy of it.

#### Decision

Option 1 is selected. Two boolean settings, one per external provider configuration, are combined with the user's own state by a single predicate that every enforcement point and every interface surface consults.

### Database model changes

One migration adds one column to each provider configuration:

* `settings.ldap_disable_password_management`, boolean, `NOT NULL DEFAULT false`.
* `openidprovider.disable_password_management`, boolean, `NOT NULL DEFAULT false`.

Both default to false, so an existing installation behaves exactly as it did before the upgrade until an administrator changes something. They are two columns rather than one because they belong to two independently administered configurations, which is the same reason the setting is per provider in the first place.

### Deciding whether password management is disabled

For a given user the predicate is evaluated in this order:

1. An administrator is never affected.
2. A user who has a local password is never affected.
3. A user linked to LDAP is affected when the LDAP setting is enabled.
4. A user linked to external OIDC is affected when the provider's setting is enabled.
5. Otherwise password management stays available.

Both exemptions are deliberate. The administrator exemption exists so that an outage or misconfiguration at the identity provider can never leave an installation with nobody able to authenticate; the cost is that the setting cannot be used to stop an administrator from holding a local password, which is the right trade in a system where losing all access is unrecoverable. The local-password exemption means enabling the setting never invalidates a password somebody is already using, so the setting is safe to turn on for an existing user base and its effect is limited to accounts that authenticate externally already.

Whether a user is externally managed is inferred from the provider link plus the absence of a local password, rather than from a recorded origin. The inference is fail-safe in both directions: nobody holding a local password is ever affected, and a user who has no password yet but no provider link either - a partially enrolled local account, for instance - is not affected either. One useful property follows from step 2: if password management is disabled for a user, that user provably has no local password, which is what lets the enrollment and reset paths treat the two conditions interchangeably.

### Where it is enforced

The computed result is exposed as a field on the shared user representation, which is the single object behind profile details, the administrative user list, and the endpoint a user reads about themselves. All three surfaces therefore hide the password controls on exactly the condition Core enforces. The field is computed rather than stored, so updating a user does not write it back, and the user list loads the provider configuration once rather than once per row.

Enforcement itself sits at each point where a password can be set:

* Self-service password change, administrator-initiated password change, and administrator-initiated password reset all return `403` with an explanatory message.
* Enrollment activation accepts an activation without a password only when the predicate holds, and rejects a password-less activation from anyone else. Core does not set a password when none was sent.
* The enrollment start response carries the flag so the client can skip the password step entirely rather than presenting a step that will be rejected.
* The password reset request path branches as described below.

### Password reset and user enumeration

At a reset request, a user with no local password falls into one of three cases. An externally-managed user is sent a dedicated email explaining that password reset has been disabled by their administrator, with no token and no link. A user who is linked to an external provider but whose provider still permits local passwords goes through the normal reset flow, which is how such a user sets their first local password. Any other password-less user, such as a partially enrolled account, is skipped silently.

The response returned to the caller is identical in all three cases, and identical to the response for an email address that belongs to nobody. This is the point of the design: any distinguishable status or body on the reset request path would tell an unauthenticated caller which addresses correspond to real accounts and how those accounts are configured. Feedback for the blocked user is therefore delivered by email, to the address that would have received the reset link, which reaches the account holder without telling the requester anything.

Password reset depends on email delivery, so the reset visibility published to Edge components now combines the administrator's reset toggle with whether SMTP is actually configured, and a change to the SMTP settings triggers a fresh broadcast. Without it, configuring SMTP would not make the reset option appear until an unrelated settings change or a reconnect. This is a coarse instance-wide gate and is independent of the per-provider setting: if email cannot be sent, nobody is offered a reset.

### Client and proto changes

`InitialUserInfo` gains a `password_management_disabled` field, and `ActivateUserRequest.password` is widened from required to optional. Both changes are wire-backward-compatible: an added boolean defaults to false, and a peer that omits the new field yields false, which makes the client show the password step. The safe default is therefore the one an older or mismatched peer produces.

The desktop client reads the flag when enrollment starts, skips the password step, and omits the password key from the activation body entirely rather than sending an empty value, because an absent key is what Core treats as password-less activation. Delivering this also meant consolidating activation in the client into a single call at the computed final step of the wizard, replacing per-step activation triggers that did not cover every combination of skipped steps.

### Consequences

Administrators always retain password management, so an externally-managed administrator can never be locked out. The corollary is that this setting cannot be used to force administrators onto the external provider exclusively.

Users who already hold a local password keep it and keep the ability to change it. Enabling the setting does not remove existing passwords, and clearing them is not part of this change.

The predicate is evaluated per request, so toggling the setting takes effect on the next request with no synchronization step, and turning it off restores the previous behaviour immediately. Nothing is deleted when the setting is enabled.

Because external management is inferred rather than recorded, a user who is linked to a provider and holds no local password is treated as externally managed regardless of how the account was originally created. An explicit origin field would make this precise and remains a possible later refinement.

The reset request path stays deliberately uninformative to the caller. Any future change that returns a distinguishable status or body there reintroduces the enumeration leak that the email-only feedback exists to avoid.

## Generating AllowedIPs from ACL rules

A WireGuard client decides which traffic to send through a tunnel using the `AllowedIPs` field of its configuration. In previous Defguard versions that field came from a single static list stored on the location and maintained by hand, so every user of a location routed the same traffic through the VPN regardless of what they were actually permitted to reach. At the same time, ACL firewall rules already describe which users may reach which destinations, which meant administrators were maintaining the same information twice and keeping the two in sync themselves. Defguard 2.1 lets a location derive each user's `AllowedIPs` from the ACL rules that apply to that user.

The two concerns stay separate. Firewall rules are compiled into a `FirewallConfig` and pushed to gateways, where access is enforced. `AllowedIPs` is client-side routing: it decides what the client sends into the tunnel, and it is not an enforcement mechanism. This feature reuses the ACL data model to answer a routing question, and changes nothing about how the gateway enforces access.

### Requirements

1. The addresses in a client's configuration should come from the ACL rules that grant that specific user access, not from a location-wide list.
2. Manually maintained `AllowedIPs` must keep working, including networks that no ACL rule covers.
3. The behaviour must be opt-in per location, and a location that does not enable it must behave exactly as it did before.
4. The result has to be a valid `AllowedIPs` list: a minimal set of non-overlapping CIDRs, since WireGuard accepts neither address ranges nor overlapping entries.
5. Rule evaluation must agree with the firewall: a deny beats an allow, and only rules that are active and applied count.
6. The feature must require an appropriate license, and losing the license must not destroy the administrator's configuration.
7. Gateway-side enforcement must not change, and clients that already work must keep working.

### Considered options

**1. Resolve per user in Core, while the client configuration is built**

* Add a per-location toggle, and when it is set, compute the user's addresses from the location's active ACL rules at the moment a device configuration is generated.
* Always include the manually configured `AllowedIPs` and merge the ACL-derived addresses into them.
* Do the computation in one shared configuration builder used by every path that hands a configuration to a client.

**Pros**

* The result is derived from current data every time, so rules, group membership, alias contents and license state are all reflected without any invalidation logic.
* Nothing is stored, so there is no second copy of the policy that can drift from the rules it came from.
* No new transport, event or client-side capability is needed, and clients that predate the feature are unaffected because they only ever see a longer or shorter address list.
* Resolution happens in Core, on the side the client cannot influence.

**Cons**

* A connected client keeps its previous `AllowedIPs` until it next fetches its configuration, so a rule change is not immediately visible in client routing.
* Every configuration build performs additional queries to evaluate rules and expand aliases for that user.

**2. Store the computed addresses in the database**

Persist each user's derived `AllowedIPs` and recompute them when ACL rules change.

**Deal-breaking issue:** the result depends on rules, rule state and expiry, group membership, alias contents and license state, so each of those becomes an invalidation trigger and any missed one leaves a stored list that silently disagrees with the policy it claims to represent. Computing at read time makes the correctness problem disappear rather than managing it.

**3. Push updated addresses to connected clients when rules change**

Introduce an event that recomputes and delivers new `AllowedIPs` to affected clients as soon as an administrator edits a rule.

**Deal-breaking issue:** this requires new push infrastructure and a per-user fan-out on every rule change, in order to update a routing hint on a client whose access the gateway already enforces regardless of what the client routes. The cost is real and the security benefit is nil.

**4. Let the ACL-derived addresses replace the manual list**

Treat the toggle as a switch between two sources of `AllowedIPs`.

**Deal-breaking issue:** administrators legitimately need addresses that no ACL rule covers, and a replacing list gives them no way to express that while the feature is on. An additive merge can express everything a replacement can, and more.

#### Decision

Option 1 is selected. A location-level toggle enables ACL-derived `AllowedIPs`, the addresses are computed per user while the device configuration is built, and they are merged into the manually configured list rather than replacing it.

#### Rationale

The decisive argument is that the derived list has no independent existence. It is a view over ACL rules, group membership and alias contents, and it is meaningful only at the moment a client asks for its configuration. Storing it or pushing it turns a derived value into state that has to be kept in agreement with its own inputs, which is work that only exists because the value was stored in the first place. Recomputing on read costs a few queries per configuration fetch and removes an entire class of staleness bugs.

Accepting lazy propagation is a smaller concession than it appears, because `AllowedIPs` is not what makes a rule effective. When an administrator revokes access, the gateway stops passing that traffic as soon as the firewall configuration is pushed, whether or not the client is still routing it into the tunnel. The window between a rule change and the next configuration fetch is therefore a window of unnecessary routing, not of unauthorised access.

Merging rather than replacing follows from what the manual list is for. It holds addresses an administrator decided the tunnel should carry, and ACL rules cannot express all of them. Treating the ACL-derived addresses as an addition keeps the manual list meaning exactly what it always meant, which also makes the toggle safe to turn on and off on an existing location.

### Database model changes

The migration adds a single column to `wireguard_network`:

* `allowed_ips_from_acl` is a boolean, `NOT NULL DEFAULT false`.

There is no table for the computed addresses, which is the point of the decision above. Existing locations get `false`, so after the migration every location keeps producing exactly the configurations it produced before.

### Computing the address set

For a given location and user, Core loads the location's active ACL rules and evaluates each one against that user. A rule contributes nothing unless the user passes its source policy. Only user and group membership is examined, since this list describes what a user's device should route and not what network devices may reach.

Destinations are collected from every matching rule: the rule's own addresses and address ranges when it uses manual destination settings, the addresses and ranges of any aliases it references, and the addresses and ranges of the pre-defined Destinations attached to it. Everything collected is converted to inclusive address ranges, overlapping and adjacent ranges are merged, and the merged ranges are decomposed back into the smallest set of CIDRs that covers them. That last step is what makes the output a legal `AllowedIPs` value, and it reuses the range-merging and subnet-extraction helpers the firewall module already relied on, so both features decompose ranges the same way.

Destinations marked **Any address** are skipped rather than expanded. This reverses the behaviour of the original prototype, which returned `0.0.0.0/0` and `::/0` as soon as it saw one, on the reasoning that nothing can add more than everything. In practice that made a single any-address destination anywhere in a location silently convert every affected user to a full tunnel, which is a large routing change to infer from a firewall rule that was written to describe access. Skipping is the least surprising reading: an administrator who combines an any-address destination with specific ones still gets the specific ones, the gateway still enforces the any-address rule exactly as before, and full-tunnel routing remains something the administrator turns on deliberately by adding it to the manual list.

#### Where the computation happens

All of it sits behind one function that returns the effective `AllowedIPs` for a user in a location. It starts from the location's manual list, adds the ACL-derived addresses when the toggle is on and the license permits, then sorts and deduplicates so the output is deterministic.

Reaching every client path required consolidating configuration generation first. Device configurations were previously built in several places, spread across methods on the device model and separate code paths for the HTTP API and gRPC. Defguard 2.1 moves that into a single device access module that owns joining a device to a location and building its configuration, and every caller now goes through it: the desktop client configuration served over gRPC, configuration polling, the enrollment and onboarding flows in the proxy manager, the configuration download and device management endpoints, and network device configurations. As a result the toggle takes effect on every path that hands a configuration to a client, and there is one place where a future change to routing derivation has to be made. Network devices are handled by the same builder and are evaluated against their owning user.

Errors are contained. If the ACL computation fails, whether because ACL is disabled on the location, the license does not cover the feature, or a query fails, the failure is logged and the manual `AllowedIPs` are returned on their own. A device configuration is never withheld because the derived addresses could not be produced.

#### License behaviour

The feature requires an Enterprise license, or a license that has been granted the ACL `AllowedIPs` feature individually. Without one, the computation is skipped and the manual list is used, so an unlicensed instance produces exactly the configurations it produced before the feature existed. The location form shows the toggle with an upgrade prompt instead of an editable control.

The stored flag is left alone. Nothing is cleared when a license lapses, and restoring a valid license makes the toggle effective again with no reconfiguration, on the same principle applied elsewhere in 2.1: license state disables a feature, it does not destroy configuration.

### Consequences

Changes to ACL rules, alias contents or group membership reach a client on its next configuration fetch rather than being pushed to it, so a connected client can route according to the previous rule set for a short period. Gateway enforcement is unaffected during that window.

Gateway behaviour is unchanged in every respect. The location's static `allowed_ips` is still sent as-is in location modification events, and the gateway does not use it for peer configuration in any case.

A rule whose destination is **Any address** grants access that the client will not route unless the administrator also adds the relevant networks manually. This is intentional, and the toggle's description in the location form states it, but it is the one case where the routing list is deliberately narrower than the access the rules grant.

Because the derived list is per user, two users of the same location can now receive different `AllowedIPs`. Anything that assumed a location's clients share a routing list no longer holds.
