Email 06 - Active Directory integration
Email Solution · Previous: Internal servers: Postfix · Next: Dovecot authentication
The email servers hold no user database. Who exists, who may send, who may send to the internet and who is on which distribution list are all questions that Postfix puts to Active Directory at the moment a message arrives, through five small LDAP map files. This Article describes what was put into the directory for that, and takes each of the five queries apart.
Why the directory
The requirement was one sentence: user accounts, email aliases and email groups are stored in Microsoft Active Directory (FR7), and the directory is reached over a secured channel, LDAPS (FR8). The reason behind it is the kind of user this system has. Most "users" are devices, servers and applications that send notifications; each of them gets an account of its own, and the people who run the directory already create, disable and audit accounts. An address is switched on by a group membership and switched off by disabling the account, without anybody logging in to a mail server.
The design divides the accounts into two kinds. Email senders are the accounts of devices, servers and applications. Email readers are people who open the webmail and, by the design, do not send. An account's userPrincipalName is its mail address and its login at the same time, which is why the mail domain of each site is the name of its AD domain: ad.example.net in site 1 and ad-dc2.example.net in site 2.
Directory servers
| Site | Server | Address | Kind |
|---|---|---|---|
| 1 | DC1-A-CAD001 | 10.11.88.145 | bare metal |
| 1 | DC1-B-CAD001 | 10.11.88.146 | bare metal |
| 1 | DC1-A-VCAD001 | 10.11.16.209 | virtual |
| 1 | DC1-A-VCAD002 | 10.11.16.210 | virtual |
| 2 | DC2-A-VCAD001 | 10.12.16.209 | virtual |
| 2 | DC2-A-VCAD002 | 10.12.16.210 | virtual |
The table is the design's. The site 2 files name both servers of site 2. For site 1 only the first concept of 2018 shows files, and they name the two virtual servers only, by DNS name:
server_host = ldaps://dc1-a-vcad001.adm.example.net:636, ldaps://dc1-a-vcad002.adm.example.net:636
As built in site 2 the same line holds addresses, and the comment above it says why:
# Due to problems in LDAP clients/LDAP libraries in dual IP stacks (IPv4, IPv6) environments, this configuration (parameter "server_host") contains only IPv4 addresses. server_host = ldaps://10.12.16.209:636, ldaps://10.12.16.210:636
The mail servers are dual-stack, and the design records the outcome in one sentence: "Active Directory servers are integrated only on IPv4 addresses due to problems in LDAP libraries." What exactly failed is not written down anywhere in the Source material. Postfix tries the servers of server_host in the order given, so the second domain controller is the fallback of the first.
The channel is LDAPS on port 636 and nothing else: start_tls = no, version = 3, and the firewall rule of the first design opens TCP 636 from the mail server to the directory servers. None of the five files sets tls_ca_cert_file or tls_require_cert, and the install notes do not show the CA of the organisation being given to the LDAP client. The default of tls_require_cert is no, then and now, so the connection was encrypted but the certificate of the domain controller was not verified.
Accounts and organisational units
| Object | Where | Purpose |
|---|---|---|
vmail_svc | CN=vmail_svc,OU=Users_svc | bind account for the queries of Postfix and Dovecot |
| User accounts | OU=Users_STD | people; webmail |
| Device accounts | OU=msx_SVC,OU=Users_SVC | devices, servers and applications that send mail |
| Authorization groups | OU=RBAC_GROUPS,OU=RBAC | hold the accounts, one group per organisation |
| Role groups | OU=RBAC_ROLES,OU=RBAC | hold the authorization groups; the mail servers know only these |
| Distribution lists | OU=DISTRIBUTION_GROUPS | groups that work as mail addresses |
All paths are relative to the base DN of the domain; the design writes them for site 1, DC=ad,DC=example,DC=net. A device account is named after its service with the suffix _mail: gitlab_mail is the design's example and sensu_mail appears in the exception lists of the encryption filter.
The design calls vmail_svc a special account "which will be used to query LDAP database". Every map binds with it, a simple bind with DN and password, and the password stands in clear text in each of the five Postfix files and in the two Dovecot files, on each of the three internal servers.
Groups
The design uses a two-level pattern: an account is put into an authorization group, and the authorization groups are members of a role group.
| Authorization group | Members | Right |
|---|---|---|
SMTP_ORG, SMTP_PARTNER1, SMTP_PARTNER2 | accounts of the organisation and of each of two partners | send to addresses of the own domains |
ESMTP_ORG, ESMTP_PARTNER1, ESMTP_PARTNER2 | the same split | send to external domains |
IMAP_ORG, IMAP_PARTNER1, IMAP_PARTNER2 | the same split | use webmail |
The role groups collect them.
| Role group | Contains | Asked by |
|---|---|---|
SMTP_ACCESS | the three SMTP_ groups | Postfix, ad_allow_internal_emails.cf |
ESMTP_ACCESS | the three ESMTP_ groups | Postfix, ad_allow_external_emails.cf |
IMAP_ACCESS | the three IMAP_ groups | Dovecot |
The operations chapter of the design says it in the form of a procedure: an address is activated by adding the account to one of the SMTP_ groups, and the right to send to external domains is one more membership, in an ESMTP_ group. As the restriction lists are built (Internal servers: Postfix), the second without the first is worth nothing: the sender check asks for SMTP_ACCESS before the recipient check asks for ESMTP_ACCESS.
One detail differs between the design and the files. The design places the role groups directly in OU=RBAC_ROLES,OU=RBAC; every filter, in the first concept and in the archived files alike, looks for them one level deeper, in OU=APPS,OU=RBAC_ROLES,OU=RBAC. The files are what ran.
The five maps
| File | Parameter in main.cf | Key | Answer |
|---|---|---|---|
ad_allow_internal_emails.cf | check_sender_access in the sender list | envelope sender | OK if in SMTP_ACCESS |
ad_allow_external_emails.cf | check_sender_access in the recipient list | envelope sender | OK if in ESMTP_ACCESS |
ad_sender_login_maps.cf | smtpd_sender_login_maps | envelope sender | login that owns the address |
ad_virtual_mailbox_maps.cf | virtual_mailbox_maps | recipient | mailbox path |
ad_virtual_group_maps.cf | virtual_alias_maps | recipient | addresses of the group members |
All five begin with the same block: servers, base DN, scope = sub, the bind account. The last three are referenced as proxy:ldap:, which lets the proxymap service keep the connections open for all Postfix processes; the two access tables are plain ldap: and are opened by each smtpd process itself. The same five files lie on the mailbox server.
Allow internal and allow external differ in one word. The filter of the first:
output 1 line
(&(memberOf:1.2.840.113556.1.4.1941:=CN=SMTP_ACCESS,OU=APPS,OU=RBAC_ROLES,OU=RBAC,DC=ad-dc2,DC=example,DC=net)(userPrincipalName=%s))
Postfix replaces %s with the lookup key, the sender address, so the second half finds the account whose userPrincipalName is that address. The first half is the interesting one. A plain memberOf= matches direct membership only, and no account is a direct member of SMTP_ACCESS; the accounts are in SMTP_ORG or in a partner's group. The number between the colons is the OID of an Active Directory matching rule, LDAP_MATCHING_RULE_IN_CHAIN, which makes the domain controller walk the chain of nested groups. Without it the two-level group model could not be asked in one query. The map then needs to return something that an access table understands: result_attribute = userPrincipalName fetches a value and result_format = OK replaces it with the word OK. An address outside the group returns nothing, and the restriction list moves on. Whole files: allow internal emails, allow external emails.
Sender login maps answers the question to whom an address belongs:
output 1 line
(&(userPrincipalName=%s)(objectClass=person)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))
The second OID is the bitwise AND rule. userAccountControl is a bit field, the value 2 is the bit "account disabled", and the negated term removes disabled accounts from the result. The map returns the userPrincipalName itself: the owner of an address is the login of the same name. Whether Postfix ever asks this map in the configuration as archived is discussed in the previous Article. Whole file: sender login maps.
Virtual mailbox maps tells Postfix which recipients of the own domain exist. The filter is (&(objectclass=person)(userPrincipalName=%s)), and result_format = %d/%u/Maildir/ turns the returned address into a path made of its domain part and its local part, the mailbox below /data/vmail on the mailbox server (Mailbox server). On the internal pair the path is not used, only the fact that there is an answer. Two things are missing from this filter when it is compared with the others: it does not ask for a group and it does not exclude disabled accounts. Every person object of the domain can receive mail, activated or not. Whole file: virtual mailbox maps.
Virtual group maps is the distribution list:
search_base = OU=DISTRIBUTION_GROUPS,DC=ad-dc2,DC=example,DC=net query_filter = (&(objectClass=group)(sAMAccountName=%u)) special_result_attribute = member result_attribute = mail
It is the only map with a narrower search base, and the only one that uses %u, the local part of the recipient address: mail to name@ad-dc2.example.net looks for a group with the sAMAccountName name in that OU. special_result_attribute names an attribute that contains DNs; Postfix reads each entry that member points to and collects its mail attribute, and it does so recursively, so a list may contain a list. A new distribution list is a new group in one OU and needs no change on a mail server. In the first concept this map returned userPrincipalName; as archived it returns mail, and a member without a filled mail attribute is silently not a recipient. Whole file: virtual group maps.
One authenticated submission
sequenceDiagram participant c as Client participant p as Postfix smtpd participant d as Dovecot auth participant ad as Domain controller c->>p: EHLO, STARTTLS, AUTH with login and password p->>d: credentials over the socket private/auth d->>ad: LDAPS bind as vmail_svc, search userPrincipalName ad-->>d: DN of the account d->>ad: LDAPS bind as that DN with the password ad-->>d: bind accepted d-->>p: login accepted c->>p: MAIL FROM and RCPT TO p->>ad: is the sender in SMTP_ACCESS ad-->>p: userPrincipalName, read as OK p->>ad: is the sender in ESMTP_ACCESS ad-->>p: entry or nothing p->>ad: does the recipient exist, own domain only ad-->>p: mailbox or group members p-->>c: 250 or reject
Three things in the picture are easy to overlook. Postfix never sees the directory during authentication; it hands login and password to Dovecot and gets back yes or no. Dovecot does not compare passwords: it looks the account up as vmail_svc and then binds as the account itself, so the domain controller is the one that checks the password. And every message costs several LDAP queries on top, each check_sender_access asking more than once, because an access table is tried with the full address and then with its parts.
Dovecot uses two files of its own, one per OU, for user accounts and for device accounts, with the same bind account, the same servers and the same two matching rules. They are the subject of Dovecot authentication; the files are dovecot-ldap.conf.users and dovecot-ldap.conf.devices.
Testing
The install notes of the five servers contain no test of the maps: no postmap -q, no ldapsearch, no captured answer. The notes refer to the configuration files as attachments, and whatever was typed to prove the maps the first time was not kept. debuglevel = 0 at the end of every map is the one trace of that work: the switch to turn up while a filter does not match.
What I would do differently
- Keep the test next to the file. One
postmap -qline per map with an address that must match and one that must not would have documented the intent of each filter better than the comments do. - Decide what "activated" means for receiving. As built, sending needs a group and receiving needs only a person object.
- Give the LDAP client the CA and require a valid certificate, instead of an encrypted channel to whoever answers on the address.
- Use
leaf_result_attributein the group map. Withresult_attribute = maila group that carries amailattribute of its own returns that address next to those of its members. - Write down what the "problems in LDAP libraries" were. The workaround is in seven files per server, the reason in none.