Skip to content

Active Directory Import

openhands edited this page Nov 11, 2026 · 1 revision

A WindowsTarget list maintained by hand drifts from the domain, and the hosts that never get added are the ones nobody knows are missing. DAD can query Active Directory for computer objects and import the ones that are not already targets.

This is optional and Admin-only. Nothing changes if you do not configure a directory.

The flow

  1. Configure a directory under ActiveDirectoryConnection (/active_directory_connections): host, port, bind DN and password, search base, and the defaults to apply to whatever it finds.
  2. Start an import from /ad_import_batches/new: choose the directory, and choose workstations / servers / both.
  3. DAD queries the directory and shows the machines it found that are not already targets. Nothing has been created yet.
  4. Uncheck what you do not want, confirm, and only then are the WindowsTarget rows created — already configured with a priority band, channels, and a WinRM credential, so they poll on the next cycle.
bundle exec rails runner 'puts ActiveDirectoryConnection.enabled.pluck(:name, :host, :port)'

The preview is stored, and apply can only subtract

This is the design decision that is not obvious from the requirement, and it is a security boundary rather than a UX nicety.

A WindowsTarget row makes this server connect out to address carrying a domain credential. If the apply step took host names from the submitted form, a crafted POST would create a target for any host an attacker named and hand it the WinRM credential — SSRF with a domain login attached, arriving through a form whose only visible affordance is unchecking boxes.

So the candidate set is written server-side at preview time, into ad_import_batches.candidates, and apply intersects the submitted selection with it. It may only ever remove from that set.

posted address outcome
offered by the preview, still selected imported
offered by the preview, deselected recorded as deselected, nothing created
never offered recorded as rejected, nothing created
already a target at apply time recorded as skipped, nothing created

A name the directory never returned cannot be imported, whatever is posted. The batch row doubles as an audit trail: who ran the query, what filter, what came back, what was actually applied, and when.

A batch applies once. Replaying the POST does not re-import against a stale candidate list.

The candidate list is stored as JSON, not YAML — YAML.load on a blob that round-trips through storage is a deserialization footgun, and these are flat scalars that need no richer type. Corrupt JSON reads as an empty list rather than raising.

Three behaviours in the query that are load-bearing

Paging. Active Directory caps a single search at MaxPageSize, 1000 entries by default. A domain with more machines than that returns a truncated list, and the truncation is silent — the operator sees a plausible list with no hint that half the fleet is missing. net-ldap negotiates RFC 2696 paged results on its own, so the fix is to not defeat it: no :size limit is ever passed, and the attribute list is narrow and named rather than *.

Unknown operating system. A machine joined to AD may be Linux, an appliance, or a Windows install whose operatingSystem attribute is simply unset. It matches neither the workstation nor the server filter. Those hosts are listed but left unchecked, and reported as a count, because a filter that quietly omits machines is exactly how an operator ends up believing the fleet is fully covered when it is not. Classification reads AD's free-text operatingSystem:

operatingSystem class
Microsoft Windows Server 2019 Standard server
Microsoft Windows 10 Pro workstation
Windows 10 Pro for Workstations workstation
Ubuntu 22.04 LTS, '', unset unknown

"Server" is checked first and wins, so a SKU naming both is a server.

Domain controllers are flagged. A DC matches "servers", and bulk-enabling WinRM polling against one is a decision worth making deliberately rather than reaching by accident. primaryGroupID 516 (Domain Controllers) and 517 (Read-only Domain Controllers) are surfaced in the preview so the operator can uncheck them.

Duplicate detection matches two address forms

AD returns dNSHostName fully qualified — web01.corp.example.com. An operator adding a host by hand types web01. Matching those as different strings produces two targets for one machine, which double-polls it and interleaves two independent offset states.

So a host counts as already present if it matches the FQDN or its first DNS label, case-insensitively (LOWER() on both sides, because Postgres = is case-sensitive and AD returns hostnames in whatever case the machine registered them). The check runs at preview and again at apply, since a target may have been created by hand in between.

Two credentials, not one

field what it is for
password binds to the directory, to read computer objects
poll_username / poll_password the WinRM login applied to every host this connection imports

They are not interchangeable. The second pair exists because an imported host with no credential is a target that can never poll — a feature that looks finished and is not. A connection missing it is allowed (importing the inventory first, then setting the credential, is legitimate), but the import form says so out loud rather than letting the result look like a successful import of hosts that silently do nothing.

Both are encrypted at rest by Rails' encrypts, exactly as WindowsTarget#password is; the columns hold ciphertext. Consequences:

  • Production needs DAD_AR_ENCRYPTION_KEY and DAD_AR_ENCRYPTION_SALT; DAD refuses to boot in production without them. See Installation.
  • Rotating either value makes stored credentials unreadable.
  • Neither credential is ever rendered back to a browser, on the HTML screens or in the .json forms. The edit form shows blank password fields, and submitting them blank leaves the stored credential alone.
  • The polling credential is read from the connection at apply time. It never travels through the browser on the way back from the preview page, so it cannot be substituted by a crafted POST either.

A bind credential is required. Anonymous bind is refused rather than allowed: it typically returns nothing useful, so an operator who left the field blank would see "no new hosts" and reasonably conclude the domain was already fully imported. A loud validation beats a plausible lie.

LDAPS, StartTLS, and what the port means

port behaviour
636 LDAPS, TLS before the bind
389 StartTLS is negotiated before the bind; the UI says so explicitly

The model does not silently upgrade — it reports what will actually happen, and the screens state whether a bind is protected. Certificates are verified against the system trust store by default. WINDOWS_SSL_VERIFY=none is the existing Windows escape hatch for an internal CA, reused here rather than inventing a second knob for the same decision.

A failed query is not "no new hosts"

An unreachable directory is an ordinary condition, not a 500. The failure is recorded in last_status and shown in the UI, because otherwise "query failed" and "no new hosts" look identical, and the second one is a lie. The same reasoning as reporting an access-denied event channel as "no events" — see Windows Events.

A failed query records no batch, so there is nothing to accidentally apply.

Partial failure is expected

One bad host must not cost the other 399 — the same principle as one unreadable event-log channel not costing a host its other channels. apply returns added / skipped / deselected / rejected rather than raising, and a host that fails validation is reported with the reason while the rest of the import proceeds.

Channel names are validated, not escaped

The channels an import applies end up in a wevtutil command line executed on a remote host that DAD authenticates to with a domain credential. An unvalidated channel name is remote command execution on that machine. Names outside [A-Za-z0-9._-/] are rejected at save time rather than escaped, for the same reason as on WindowsTarget — the command is built for cmd.exe, and shell escaping there is not something to get subtly wrong.

The same reasoning applies to the host address, which builds the WinRM endpoint URL.

LDAP filter escaping

The host filter is operator-supplied text that goes into an LDAP search filter. Unescaped, a value containing *, ( or ) changes the structure of the filter: *)(objectClass=user closes the real filter and opens a new one, turning a computer lookup into something else entirely. Values are escaped per RFC 4515.

NUL is handled separately and must become the hex pair \00, not a backslash followed by a NUL byte — the byte is still a protocol delimiter either way.

The bind DN and search base are not filter text: they reach net-ldap as discrete bind-username and search-base arguments. Their format check is a well-formedness guard that turns "you pasted the wrong thing" into an error at save time rather than a confusing bind failure at query time — it is deliberately permissive inside, because RFC 4514 allows parentheses in a DN value and real DNs carry spaces, apostrophes and non-ASCII names.

Rights

active_directory_connections and ad_import_batches are Admin-only, for reading as well as writing. That is deliberately stricter than windows_targets, where reading the list is a Viewer action: the sensitive act here is not saving a row, it is the enumeration. A query returns the entire machine inventory of a domain — hostname, OS version, creation date — which is reconnaissance-grade information about the internal network regardless of what gets imported afterwards.

See Access Control.

Limits worth knowing

  • The LDAP call is not exercised in CI. There is no domain controller in the test environment, so the search sits behind an injectable client — the same seam shape as the WinRM transport. Filter construction, escaping, classification, and the address matching are tested; the bind and paged search against a real AD are not.
  • operatingSystem is free text. Classification is a heuristic over AD's own strings, not a lookup against an attribute AD does not have. A machine whose OS string DAD does not recognise is reported as unknown rather than guessed at.
  • Import is one-directional. DAD does not write to the directory, and removing a WindowsTarget does not touch the computer object.
  • A connection's polling credential is shared across every host it imports, which is the normal setup for a fleet. Per-host overrides remain available on the individual target.

Related: Windows Events, Access Control, Interface.

Clone this wiki locally