diff --git a/docker-compose.yml b/docker-compose.yml index d4fd4f2..32b1aa5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,18 @@ services: # default and enabled from the Admin console; this is the only email # secret and it is never stored in the DB. Leave unset until configured. SMTP_PASSWORD: ${SMTP_PASSWORD:-} + # D13 — domain authentication. REQUIRED: the suite stores no passwords and + # has no local fallback, so a wrong value here means nobody can sign in. + # Connect to the DOMAIN NAME, never a DC or an IP (SAN + DNS round-robin + # across six DCs). See server/ldap_auth.py and DEPLOYMENT.md. + LDAP_DOMAIN: ${LDAP_DOMAIN:-prime.local} + LDAP_HOST: ${LDAP_HOST:-prime.local} + # Trust anchor for the DC certificate — public CA certs, baked into the image + # at server/certs/. Override only to point at a mounted bundle. + LDAP_CA_FILE: ${LDAP_CA_FILE:-/app/server/certs/prime-ca-chain.pem} + # AD group required to sign in. Empty = any domain account. This is the + # initial value; the live one is set in the Admin console (T10.5). + LDAP_REQUIRED_GROUP: ${LDAP_REQUIRED_GROUP:-} # Optional — read-only SQL Server connection to the Micron asset catalog, # which backs the asset picker in the work package creator. Leave unset and # the picker cleanly falls back to manual entry (see server/assets_db.py). @@ -46,10 +58,15 @@ services: condition: service_healthy # waits for postgres to accept connections networks: - internal - # Reaching the Micron database means leaving this compose project, and - # `internal` is deliberately egress-free. `outbound` is attached to the api - # container ONLY — the database and backup containers stay sealed. Detach it - # again if you are not using the Micron asset picker. + # Reaching the Micron database — and, since D13, the domain controllers — + # means leaving this compose project, and `internal` is deliberately + # egress-free. `outbound` is attached to the api container ONLY; the database + # and backup containers stay sealed. + # + # DO NOT DETACH THIS. It used to be optional ("detach it if you are not using + # the Micron asset picker"), but authentication now needs a route to + # prime.local:636. Without it every sign-in fails and there is no local + # password fallback to fall back to. - outbound db: @@ -113,7 +130,7 @@ networks: # An ordinary bridge network, i.e. one that HAS a default gateway. `internal` # above removes the gateway entirely, which blocks not just the internet but # the LAN and the VPN too — so the api container needs this second network to - # reach the Micron asset database. Attached to `api` alone: `db` and `backup` - # remain on `internal` only and still have no way off the host. - # Detach it from api if you are not using the Micron asset picker. + # reach the domain controllers (LDAPS, D13) and the Micron asset database. + # Attached to `api` alone: `db` and `backup` remain on `internal` only and + # still have no way off the host. Required — see the note on the api service. driver: bridge \ No newline at end of file diff --git a/docs/waves/decisions-2026-08-21.md b/docs/waves/decisions-2026-08-21.md index 7f02d25..fcd2624 100644 --- a/docs/waves/decisions-2026-08-21.md +++ b/docs/waves/decisions-2026-08-21.md @@ -107,17 +107,45 @@ These are the ways this change goes wrong, and each has a done-when check in wav `532` password expired, `533` disabled, `775` locked) are useful in the log and must not reach the response body. -### Open, to confirm in the PR rather than decide alone +### Answered August 21, 2026 — both were raised as open and both were decided -- **Break-glass.** Removing `password_hash` means an unreachable DC locks out *everyone*, - admins included. See `T10.2`. -- **Username ↔ `sAMAccountName` mapping.** Existing accounts were created by - `manage_users.py` with hand-typed usernames. Criterion 4 holds only where those match the - directory's `sAMAccountName`. The production account list must be checked against AD - before this deploys; a mismatch means an existing admin gets a *second*, JIT-provisioned - account at the default role instead of keeping their admin. `auth.find_user` already - matches on username **or** email case-insensitively, which covers some of the gap but not - all of it. +**Break-glass: none. LDAPS is the only way in.** Asked and reaffirmed after the lockout risk +was stated. There is no emergency local account, no env-var bypass, and no CLI-minted +session. The consequence is explicit and belongs in the runbook rather than being discovered: +**if the domain is unreachable, or `LDAP_CA_FILE` is wrong, or the required group is +misconfigured, nobody can sign in — including admins — and no amount of shell access fixes +it except correcting the configuration and restarting.** `T10.5`'s validate-on-save guard is +therefore not a nicety; with no fallback it is the only thing standing between a typo in the +group field and a total outage. + +Three things follow, and they are done-when checks in wave 10 rather than advice: + +- The startup log must state whether LDAP is configured and reachable, so a broken deploy is + visible in `docker compose logs api` and not only at the login box. +- `/api/health` stays exempt from auth (it already is) so the outage is diagnosable. +- The group setting cannot be saved without proving the saving admin is a member. + +**Identity: bind on `sAMAccountName`, match on `sAMAccountName` *or* `mail`.** A simple bind +can only carry one identifier, and AD accepts the UPN form — so the bind is +`sAMAccountName@prime.local` and that is what the login box takes. Matching an existing local +row is a separate question, and it uses **both**: after a successful bind the directory's +`sAMAccountName` and `mail` are both read, and `auth.find_user` is extended to match a local +row on either, case-insensitively. That is what keeps an existing admin's role whether their +hand-typed username was `c.schaefer` or `c.schaefer@prime-controls.com`. + +Two consequences worth knowing: + +- The mail domain (`prime-controls.com`) is not the AD domain (`prime.local`), so `mail` is + never a valid bind string. It is a matching key only. +- If someone types an address at the login box, the local part is used as the + `sAMAccountName` — **one** bind attempt, never several, because each failed bind counts + against the domain lockout policy. That assumes the mail local part equals the + `sAMAccountName`. Where it does not, the person must type their short logon name; this is + logged when it happens and documented in `T10.8`. +- The production `users` table should still be compared against AD before this deploys. A + row matching on neither key gets a *second*, JIT-provisioned account at the default role + rather than keeping its admin. Matching on two keys narrows that risk; it does not remove + it. ### Explicitly out of scope diff --git a/docs/waves/wave-10.md b/docs/waves/wave-10.md index f344a3e..2dba494 100644 --- a/docs/waves/wave-10.md +++ b/docs/waves/wave-10.md @@ -82,8 +82,10 @@ account, `last_login_at`, the session cookie. Rework the throttle so the local c failure mode to avoid is this endpoint being usable to lock domain accounts out of Windows. Log directory error-49 sub-codes for diagnosis; return the same generic message regardless. -Decide the break-glass question here (see **Open** in the decision doc) and record the answer -in this file before implementing, because it changes this task's shape. +**Break-glass: none — decided Aug 21, see the decision doc.** LDAPS is the only way in, so +this task adds no fallback path. What it must add instead is *visibility*: a startup log line +stating whether LDAP is configured and whether the DC answered, because with no fallback a +misconfigured deploy is indistinguishable from a forgotten password at the login box. **Done when:** diff --git a/server/.env.example b/server/.env.example index 6087901..984c7aa 100644 --- a/server/.env.example +++ b/server/.env.example @@ -21,6 +21,40 @@ AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above # How long a login lasts before re-authentication (hours). Default 12. # AUTH_SESSION_HOURS=12 +# ── Domain authentication, D13 (REQUIRED — there is no fallback) ─────────────── +# The suite stores no passwords. Sign-in is an LDAPS simple bind against the +# domain, so if this is misconfigured NOBODY CAN SIGN IN, admins included. There +# is deliberately no local break-glass account (decided Aug 21 2026 — see +# docs/waves/decisions-2026-08-21.md). Check `docker compose logs api` on startup: +# the API logs one line saying whether LDAP is configured and reachable. +# +# Connect to the DOMAIN NAME, never a DC hostname and never an IP. Every DC's +# certificate carries `prime.local` in its SAN, so the domain name both passes +# hostname validation and round-robins across all six DCs. An IP fails with +# `hostname mismatch` (there is no IP SAN) and the only way to force it through is +# to disable validation, which must not happen — domain passwords cross this link. +# LDAP_DOMAIN=prime.local +# LDAP_HOST=prime.local +# LDAP_PORT=636 + +# Trust anchor: PRIME CONTROLS ROOT CA + PRIME CONTROLS ISSUING CA 1 as a PEM +# bundle. These are PUBLIC certificates — no private key, nothing issued to this +# app, nothing to request from IT. The repo ships a verified copy and the default +# points at it, so you only set this to override with a mounted file. +# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem + +# An AD group required to sign in. Empty means every domain account may sign in. +# This is the INITIAL value and the fallback; the live value is set in the Admin +# console, which refuses to save a group that does not resolve or that the saving +# admin is not a member of. Nested groups count. +# LDAP_REQUIRED_GROUP=WP-Suite-Users + +# Bind/connect timeout, and how many extra CONNECT attempts to make. Retries never +# apply to a rejected password — each failed bind counts against the domain lockout +# policy, so guessing would lock real accounts out of Windows. +# LDAP_TIMEOUT_SECONDS=8 +# LDAP_CONNECT_RETRIES=2 + # ── Email notifications (optional) ───────────────────────────────────────────── # WP-assignment emails are OFF by default and are turned on from the Admin # console (Notifications & email card), where the SMTP host/port/from-address diff --git a/server/certs/prime-ca-chain.pem b/server/certs/prime-ca-chain.pem new file mode 100644 index 0000000..e331e65 --- /dev/null +++ b/server/certs/prime-ca-chain.pem @@ -0,0 +1,66 @@ +# CN=PRIME CONTROLS ROOT CA +-----BEGIN CERTIFICATE----- +MIIFHzCCAwegAwIBAgIQOnMIcdMiP6pELiXfh2ZmLzANBgkqhkiG9w0BAQsFADAhMR8wHQYDVQQD +ExZQUklNRSBDT05UUk9MUyBST09UIENBMCAXDTIxMDkwOTEzMjM1OFoYDzIwNTEwOTA5MTMzMzU2 +WjAhMR8wHQYDVQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMIICIjANBgkqhkiG9w0BAQEFAAOC +Ag8AMIICCgKCAgEAri9Vkf+l3bnjGXWMEX15BYRnQTSKUStctG2NBppJ/lwj2LbHAvHf6HdkJAxx +2lqDiG0R+D9NcGEi427XOQ78Nc9aJe4jOwip5u5Md6Szwmu4QDRjUy11dDBvRoftD550052O1WOV +0OY5hxcZIo7bOfqDLesHG/Y74GJvrYai/4xq440uN6iTaMmsfzbIahIXP39NhuW4i4dgkQkRSfqX +y8i3AGS8WhpViVvIlMgGXRrCcBW8MnVp32OvKE2MqQnjZI2i5f8wMT4L0J0DXlSPbV6FPLdpBXl5 ++OX+9qcEH6hI+Mv8sC/xGLAt/wwBP6E2kM2lovGGIUhBsay0UM03PJsh4r6q3HV5gpH6uYZlOgOY +UW59pNT/VyESdZb4kfEGHNHrHy3uNb8Q71UzQxrq/UVKXnBUMN/1PszV4YA08ZaYf2EGItZoNM3v +uTOJIifgtAo1DxaTygmglgk8CHlaN4IU8jotrzdksLYQ9MQaD5xnYADCnIf1eZ5VCF+fhpgQOCCV +4TfJ20rR14+jy6L4yKYGgtvg8H+hvhucOOnktpV/zLv4H9pItIzbQPuxNdrEmco2i7zrsMuaNYD/ +tmbGf4mhy20zFOkdOjXGeb4cTF4HIBh3aue2T7o08vi1pkVN+8RXa7lZNbLJnpgtfTr9DKBpW2Fd +LqAb1zN2Azorg3kCAwEAAaNRME8wCwYDVR0PBAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFMoWYtRcmyQQmB3AD4DPiau0r9XRMBAGCSsGAQQBgjcVAQQDAgEAMA0GCSqGSIb3DQEBCwUA +A4ICAQB0XhWlrK81ZKW9bBlbQe/i6js7ciDhwObgKB0FGZJH8rqK5HFwM/wZbh9h0t+YPk/ev6oG +BXU0rJUr1YVMERBb37c/rHBU4Bnv9r6cLhsfiTTcVGqZTM7JE9JfdmJQ10V1awSFoxU1cYMSzot9 +TyrspkZJ6JFJH2wdhZirIySQHH5WuqEOdG6g9/bDW2X27B0S62s8WNIfP+gugkTDkiHQvoUDi8GY +/DA29lZTKPI8PhmKYZvw+fi1weqIYsFLk9pvHW21nIv6qAT7nfuGvuUR5siB7sFbw80XEX6Em8O0 +0lnP6u+vecBqLK7D34X+aupDlkZlZdPoa0EaTwvTO8pkecZCdMLcTkB2quc/fcyCmVdj4CGn3yND +cUUR5wZbHtuCU27Rc4d3rY0gxPNpK3EXkTSOQL7BgR6EkwwNwUqeYmFb/SyXYeSqpdChvsWKgRrP ++8n27SeJ01ezk8GMDC0YcOJAAHxXqqOAJo1IwxVvpRkoljKIAprQwHAGUNTmKy+SJXT73/LpPTX+ +RnbNTG27UfYONh5/DdkGc1wwmIp1X1a1tAb3MNRasiBGlYJ3NGDHVUwgEtmcVeVXWsj1ZhaXq2YX +TFsDl9m1IMhFD1n8pLJTLd7AT8Exxga+OzFjMzDu0uzKw3KoAVIX/IgfybdKexVT4Z+nBCY9N+n9 +/vpujg== +-----END CERTIFICATE----- +# CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local +-----BEGIN CERTIFICATE----- +MIIH/jCCBeagAwIBAgITdQAAAAI3j8Wt9kAshQAAAAAAAjANBgkqhkiG9w0BAQsFADAhMR8wHQYD +VQQDExZQUklNRSBDT05UUk9MUyBST09UIENBMB4XDTIxMDkwOTE0NDUxM1oXDTM2MDkwOTE0NTUx +M1owVDEVMBMGCgmSJomT8ixkARkWBWxvY2FsMRUwEwYKCZImiZPyLGQBGRYFcHJpbWUxJDAiBgNV +BAMTG1BSSU1FIENPTlRST0xTIElTU1VJTkcgQ0EgMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC +AgoCggIBANvhsMj7RooZ+FzAExgWLex++F1QsdDqcGkwqjaUI5AziI4yb/FMqnO93Sus60hiqql+ +hfxW/a55huBfo54kOyp5oWAjd0akfJQvFY8sXsWIGz07wiy0msvZDtHdBXy561zb0SSNpJS1Vceb +ihyDvM5Tyc6QAzO1u76QJPkegn+v6lpWmodba0kWVvRg/P3SLCnq1LdU8oA1VhIiCkCbdry0Syop +bVX0+evzINGmv81VbHct8ptZTBLctM11C16IwPEmntvOqVKpT166/aUO8FMqyHQQZ32ZHBp6ORNu +WlCN/EDdgT2s7Vy9/7Hr6gy1IPjEGNYq1yYxcpth+6/RgvxFnt9SWr7B9qKtYe1rN8r+6qYVw4ne +c0L7vRvw8HTjOJ9EQnPfID9+34Y8OQkqIHnnF80yjMGCdvh/tR/tXsUlz+Byt9m5MFdhPE22MX1x +jyCJFug1Ufso8toAVZcavsRfX0ygeUkv+zZDZKHmF910rB8Cdb980cpKtFvx/v0BNWnzgAhgGkqO +ttUf0PKUeGDIW6DMUKhs5JM7ED5rkepvNG5vePDVy/YidhbM4x1ph+HFVkHgT6rd1M4NeCm818C0 +MVsofXdOOdbjmkQyxPGukl9EG1ukYMfUIQpFozTbbHKNUDfqcdHu8Qm39K7opwxMOiLprXGRSgx2 +vMHI82mtAgMBAAGjggL6MIIC9jAQBgkrBgEEAYI3FQEEAwIBADAdBgNVHQ4EFgQU94Bw+7t8JXHx +VZoWN/pFMo005ZwwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud +EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAUyhZi1FybJBCYHcAPgM+Jq7Sv1dEwggEvBgNVHR8EggEm +MIIBIjCCAR6gggEaoIIBFoaByGxkYXA6Ly8vQ049UFJJTUUlMjBDT05UUk9MUyUyMFJPT1QlMjBD +QSxDTj1QUklNRS1ST09UQ0EsQ049Q0RQLENOPVB1YmxpYyUyMEtleSUyMFNlcnZpY2VzLENOPVNl +cnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y2VydGlmaWNhdGVSZXZv +Y2F0aW9uTGlzdD9iYXNlP29iamVjdENsYXNzPWNSTERpc3RyaWJ1dGlvblBvaW50hklodHRwOi8v +Y3JsLnByaW1lLWNvbnRyb2xzLmNvbS9DZXJ0RW5yb2xsL1BSSU1FJTIwQ09OVFJPTFMlMjBST09U +JTIwQ0EuY3JsMIIBNAYIKwYBBQUHAQEEggEmMIIBIjCBuwYIKwYBBQUHMAKGga5sZGFwOi8vL0NO +PVBSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EsQ049QUlBLENOPVB1YmxpYyUyMEtleSUyMFNl +cnZpY2VzLENOPVNlcnZpY2VzLENOPUNvbmZpZ3VyYXRpb24sREM9cHJpbWUsREM9bG9jYWw/Y0FD +ZXJ0aWZpY2F0ZT9iYXNlP29iamVjdENsYXNzPWNlcnRpZmljYXRpb25BdXRob3JpdHkwYgYIKwYB +BQUHMAKGVmh0dHA6Ly9jcmwucHJpbWUtY29udHJvbHMuY29tL0NlcnRFbnJvbGwvUFJJTUUtUk9P +VENBX1BSSU1FJTIwQ09OVFJPTFMlMjBST09UJTIwQ0EuY3J0MA0GCSqGSIb3DQEBCwUAA4ICAQAw +2SIuBMB8JWC/YGbh3LJDt9T/z1BwEniLwEKu4SyBMfW3qJoLR0Zps8xHlCIlUjisZBSilHDNW4uW +4891yqg104OR0dx94dQV2Y6Aw9V4tvlw+GGnWpHbUNwP3/JizlndR6ZdhdnJlIt6g7xCy4LwyXrw +f22CibC1EWUVnsYJ8ez+LxSprWiUaEME8+bEaVuajXRitYyoG0asvtESeBhR3lwaxuzBqyAuGEfc +P8pO/fgjigripPScXnp6opQRzZ12VSNsN8BUmtCfsqX8DK7iRsn/QLZtOKVf6kapPkorovetKwai +wasE2mpKJweKYhGwWXdHrXZfCp6biTzG0oX4DGke7tHxdA92ArgmPR4SsNnpekUGcbUgkdpOc0sC +mt+LXzRvFKJNd205u/FvpZlBzRZl+NQ++9OGAywyzOb4Lxli49G0yFQ5Fpq0UElqDKiLzUqUR2Ye +qDT9nMJNTZEyP6hEjQQ+tSD9XFIlFWD83AyBHhRNKKZfPiu4mJxNYtW1ltVu5Z1EvBfbcRwc0lQa +VKGQS23sSIx6heq2q5FqhnYZ15zSXqaAH680Up5mOkeGIfm45rMIFl/aMAJ2Fntl+Taebosf2rv5 +M/QOJs494ePbXxCJ1kzCLaqgucDXtadJ7ZV2AKhu3B9OiDrS8sv990Yn95ItlpthpVetiXxLHg== +-----END CERTIFICATE----- diff --git a/server/ldap_auth.py b/server/ldap_auth.py new file mode 100644 index 0000000..b444d4c --- /dev/null +++ b/server/ldap_auth.py @@ -0,0 +1,402 @@ +"""LDAPS authentication against the Windows domain — D13, built in T10.1. + +The suite stores no passwords. A sign-in is a **simple bind** to +`ldaps://prime.local:636` as `@prime.local` using the password the +person typed; a successful bind IS the authentication. This module owns that +conversation and nothing else — it does not touch the database, does not issue +sessions, and does not decide what anyone is allowed to do. `server/auth.py` and +the `login` route in `server/app.py` do that. + +WHY `prime.local` AND NOT A DC NAME OR AN IP (this is load-bearing, do not "fix" it): +every domain controller's certificate carries `prime.local` in its SAN alongside its +own hostname, and the domain name round-robins in DNS across all six DCs published +in `_ldap._tcp.prime.local`. So connecting to the domain name both passes hostname +validation and gives failover in one move. Connecting to `192.168.3.37` instead +fails with `hostname mismatch` — the DC certificates carry no IP SAN — and the only +way to "fix" that is to disable the check, which is the one thing that must not +happen here (see below). + +THE CLIENT PRESENTS NO CERTIFICATE. This module is the TLS *client*; clients verify, +they do not present. What it needs is a trust anchor: `PRIME CONTROLS ROOT CA` plus +`PRIME CONTROLS ISSUING CA 1`, shipped as a PEM bundle at `server/certs/`. Those are +public certificates — no private key, nothing secret, nothing issued to this app. + +TWO WAYS THIS GOES CATASTROPHICALLY WRONG, both guarded here: + + 1. An EMPTY PASSWORD. In LDAP a simple bind with an empty password is an + *anonymous* bind, and it SUCCEEDS. Without an explicit guard a blank password + authenticates as whatever username was submitted. `verify()` therefore rejects + an empty or whitespace-only password BEFORE `bind()` is ever called. If you are + refactoring this file and that check looks redundant, it is not. + + 2. `validate=ssl.CERT_NONE`. It still encrypts, so it fails silently — what it + loses is the ability to tell the real DC from someone who terminates the TLS + session, harvests the domain password and relays the bind onward. Domain + credentials cross this channel, so that turns an app compromise into a Windows + compromise. `CERT_REQUIRED` with an explicit CA file is the only mode here, and + the system trust store is deliberately NOT used: it currently trusts five other + self-signed CAs on this estate, any of which could issue a DC-shaped cert. + +FAILED BINDS COUNT AGAINST THE DOMAIN LOCKOUT POLICY. That is why nothing in here +retries a rejected credential — only genuine network failures are retried, and a +`bind()` that returns False is final. The caller throttles before it gets this far +(T10.2); this module's job is not to make the problem worse. + +Unconfigured is a first-class state, as it is for `MICRON_DB_URL` in `assets_db.py`: +with no CA bundle or no `ldap3` installed, `is_configured()` is False and `verify()` +returns `UNCONFIGURED` rather than raising. Since D13 leaves no local password +fallback, the caller must surface that state loudly — an unconfigured deploy and a +mistyped password look identical at the login box otherwise. +""" +import logging +import os +import re +import ssl +from dataclasses import dataclass +from typing import Optional + +log = logging.getLogger("wpsuite.ldap") + +# ldap3 is pinned in requirements.txt. The import is guarded rather than assumed so +# that this module — and the test suite that imports it — still loads on a checkout +# where the dependency has not been installed. `is_configured()` reports the truth, +# and the startup line from `describe()` says so out loud. +try: + from ldap3 import Server, Connection, Tls, SIMPLE, SUBTREE, NONE + from ldap3.core.exceptions import ( + LDAPException, + LDAPSocketOpenError, + LDAPSessionTerminatedByServerError, + LDAPCertificateError, + ) + from ldap3.utils.conv import escape_filter_chars + HAVE_LDAP3 = True +except ImportError: # pragma: no cover + HAVE_LDAP3 = False + LDAPException = LDAPSocketOpenError = Exception + LDAPSessionTerminatedByServerError = LDAPCertificateError = Exception + + def escape_filter_chars(x, encoding=None): # type: ignore[misc] + raise RuntimeError("ldap3 is not installed") + + +# ── configuration ───────────────────────────────────────────────────────────── +# Module level, matching how server/auth.py reads AUTH_SESSION_HOURS. Tests patch +# these attributes directly rather than re-importing. +DOMAIN = os.getenv("LDAP_DOMAIN", "prime.local") +# Defaults to the domain name on purpose — see the module docstring. Override only +# if you have a reason, and never with an IP address. +HOST = os.getenv("LDAP_HOST", "") or DOMAIN +PORT = int(os.getenv("LDAP_PORT", "636")) +CA_FILE = os.getenv("LDAP_CA_FILE", "") or os.path.join( + os.path.dirname(os.path.abspath(__file__)), "certs", "prime-ca-chain.pem" +) +TIMEOUT_SECONDS = int(os.getenv("LDAP_TIMEOUT_SECONDS", "8")) +# Retries apply to CONNECT failures only, never to a rejected credential. Two extra +# attempts covers the realistic case: DNS round-robin handed out a DC that is +# rebooting for patching. +CONNECT_RETRIES = int(os.getenv("LDAP_CONNECT_RETRIES", "2")) +# The initial value and the fallback for the admin-console setting added in T10.5. +REQUIRED_GROUP = os.getenv("LDAP_REQUIRED_GROUP", "") + +# AD's "member of, transitively" extensible match. Plain `memberOf` is DIRECT +# membership only and would wrongly refuse anyone who is in a nested child group, +# which is how most estates actually organise access. +NESTED_MEMBER_RULE = "1.2.840.113556.1.4.1941" + +_USER_ATTRS = ["sAMAccountName", "mail", "displayName", "userPrincipalName"] + +# Reason codes. Machine-readable, for logs and for the caller's branching — never +# for a response body, because several of them would leak whether an account exists. +OK = "ok" +EMPTY_INPUT = "empty_input" +UNCONFIGURED = "unconfigured" +UNREACHABLE = "unreachable" +UNTRUSTED = "untrusted" +BAD_CREDENTIALS = "bad_credentials" +NOT_IN_GROUP = "not_in_group" +NO_DIRECTORY_ENTRY = "no_directory_entry" +GROUP_NOT_FOUND = "group_not_found" + +# AD returns these as `data ` inside an error-49 message. Kept for the log +# only: telling an unauthenticated caller "your password expired" confirms the +# account exists, which `login()` goes out of its way not to do. +_ERR49 = { + "525": "no such user", + "52e": "bad password", + "530": "not permitted at this time", + "531": "not permitted at this workstation", + "532": "password expired", + "533": "account disabled", + "701": "account expired", + "773": "must change password", + "775": "account locked out", +} +_ERR49_RE = re.compile(r"data\s+([0-9a-fA-F]{3})") + + +@dataclass(frozen=True) +class LdapResult: + """Outcome of a bind attempt. + + `ok` is the only field the caller should branch on for allow/deny. `reason` and + `detail` are for logs. `sam`/`mail`/`full_name` are populated only on success and + are what T10.4 uses to match or provision the local account. + """ + ok: bool + reason: str = "" + detail: str = "" + sam: str = "" + mail: str = "" + full_name: str = "" + upn: str = "" + + @property + def is_config_problem(self) -> bool: + """True when the failure is ours, not the user's. With no local password + fallback (D13) these must be logged as errors and surfaced to an operator — + otherwise a broken deploy is indistinguishable from a forgotten password.""" + return self.reason in (UNCONFIGURED, UNREACHABLE, UNTRUSTED, GROUP_NOT_FOUND) + + +def base_dn(domain: Optional[str] = None) -> str: + """`prime.local` -> `DC=prime,DC=local`.""" + d = (domain or DOMAIN or "").strip().strip(".") + return ",".join(f"DC={part}" for part in d.split(".") if part) + + +def is_configured() -> bool: + """Whether a bind could even be attempted. Deliberately does not touch the + network — `selftest()` does that.""" + return bool(HAVE_LDAP3 and HOST and CA_FILE and os.path.isfile(CA_FILE)) + + +def describe() -> str: + """One line for the startup log. D13 removed the local password path, so an + operator needs to see this in `docker compose logs api` rather than discovering + it when the first person cannot sign in.""" + if not HAVE_LDAP3: + return "LDAP auth DISABLED — ldap3 is not installed. No one can sign in." + if not CA_FILE or not os.path.isfile(CA_FILE): + return (f"LDAP auth DISABLED — CA bundle not found at {CA_FILE!r}. " + f"No one can sign in. Set LDAP_CA_FILE.") + group = REQUIRED_GROUP or "(none configured — every domain account may sign in)" + return (f"LDAP auth enabled — ldaps://{HOST}:{PORT}, domain {DOMAIN}, " + f"CA {CA_FILE}, required group: {group}") + + +def _tls() -> "Tls": + """The only TLS configuration in this module. + + `CERT_REQUIRED` plus an explicit `ca_certs_file`. ldap3 performs the hostname + check against the certificate's SAN itself whenever validate is not CERT_NONE, + which is what makes connecting by IP fail instead of quietly succeeding. + """ + # No `version=` pin: ldap3's default negotiates the highest version both ends + # support, which against these DCs is TLS 1.3. Pinning PROTOCOL_TLSv1_2 here + # would silently DOWNGRADE every connection to 1.2. + return Tls( + ca_certs_file=CA_FILE, + validate=ssl.CERT_REQUIRED, + ) + + +def _server() -> "Server": + return Server( + HOST, port=PORT, use_ssl=True, tls=_tls(), + connect_timeout=TIMEOUT_SECONDS, get_info=NONE, + ) + + +def _err49(result: Optional[dict]) -> str: + """Human-readable AD sub-code from a bind failure, for the log only.""" + msg = ((result or {}).get("message") or "") + m = _ERR49_RE.search(msg) + if not m: + return ((result or {}).get("description") or "invalid credentials") + code = m.group(1).lower() + return f"{code} ({_ERR49.get(code, 'unrecognised sub-code')})" + + +def normalize_username(raw: str) -> str: + """Reduce whatever was typed in the login box to a `sAMAccountName`. + + People type their email address. The mail domain here (`prime-controls.com`) is + not the AD domain (`prime.local`), so an address is never a valid bind string — + the local part is used instead. This assumes the mail local part equals the + sAMAccountName, which is the norm but not guaranteed; where it differs the person + must type their short logon name, and we log it so that is diagnosable. + + ONE candidate is produced, never a list to try in turn: every rejected bind + counts against the domain lockout policy, so guessing would let a handful of + login attempts lock a real account out of Windows. + """ + name = (raw or "").strip() + if not name: + return "" + # DOMAIN\user, as typed by anyone used to a Windows logon prompt. + if "\\" in name: + name = name.rsplit("\\", 1)[1].strip() + if "@" in name: + local, _, dom = name.partition("@") + log.info("login input %r looks like an address; binding as sAMAccountName %r " + "(mail domain %r is not the AD domain)", name, local.strip(), dom) + name = local.strip() + return name + + +def _resolve_group_dn(conn, group: str) -> Optional[str]: + """Accept either a distinguished name or a plain group name, return a DN.""" + g = (group or "").strip() + if not g: + return None + if "," in g and "=" in g: + return g # already a DN + esc = escape_filter_chars(g) + conn.search(base_dn(), f"(&(objectClass=group)(|(cn={esc})(sAMAccountName={esc})))", + search_scope=SUBTREE, attributes=["cn"], size_limit=2) + if not conn.entries: + return None + if len(conn.entries) > 1: + log.warning("group %r is ambiguous in the directory (%d matches); using %s", + g, len(conn.entries), conn.entries[0].entry_dn) + return conn.entries[0].entry_dn + + +def member_of(conn, sam: str, group: str) -> bool: + """Nested-group-aware membership test for an already-bound connection.""" + dn = _resolve_group_dn(conn, group) + if not dn: + log.error("required group %r does not resolve in %s — refusing the sign-in. " + "This is a configuration fault, not a bad password.", group, base_dn()) + raise LookupError(GROUP_NOT_FOUND) + filt = (f"(&(sAMAccountName={escape_filter_chars(sam)})" + f"(memberOf:{NESTED_MEMBER_RULE}:={escape_filter_chars(dn)}))") + conn.search(base_dn(), filt, search_scope=SUBTREE, + attributes=["sAMAccountName"], size_limit=1) + return bool(conn.entries) + + +def verify(username: str, password: str, required_group: Optional[str] = None) -> LdapResult: + """Authenticate against the domain. The only entry point the app should call. + + `required_group` overrides the `LDAP_REQUIRED_GROUP` default so the admin-console + setting (T10.5) wins. Pass an empty string to mean "no group gate"; pass None to + use the configured default. + """ + sam = normalize_username(username) + + # ── guard 1: no bind on empty input ────────────────────────────────────── + # An empty password makes the bind below an ANONYMOUS bind, which SUCCEEDS and + # would authenticate `sam` without proving anything at all. Must stay before + # every return path that reaches bind(). + if not sam or not (password or "").strip(): + return LdapResult(False, EMPTY_INPUT, "empty username or password") + + if not is_configured(): + return LdapResult(False, UNCONFIGURED, describe()) + + group = REQUIRED_GROUP if required_group is None else required_group + bind_user = f"{sam}@{DOMAIN}" + last_network_error = "" + + # Retries cover CONNECT failures only: DNS round-robin across six DCs will + # eventually hand out one that is rebooting. A rejected credential returns + # immediately and is never retried — each attempt counts against AD lockout. + for attempt in range(1, max(1, CONNECT_RETRIES + 1) + 1): + conn = None + try: + conn = Connection( + _server(), user=bind_user, password=password, + authentication=SIMPLE, read_only=True, + receive_timeout=TIMEOUT_SECONDS, raise_exceptions=False, + ) + if not conn.bind(): + detail = _err49(conn.result) + log.info("bind refused for %r: %s", sam, detail) + return LdapResult(False, BAD_CREDENTIALS, detail) + + # Bound as the user. AD lets an account read its own object, so no + # service account is needed for either of the next two steps. + conn.search(base_dn(), + f"(&(objectClass=user)(sAMAccountName={escape_filter_chars(sam)}))", + search_scope=SUBTREE, attributes=_USER_ATTRS, size_limit=1) + if not conn.entries: + log.error("bind succeeded for %r but the account has no readable " + "directory entry under %s", sam, base_dn()) + return LdapResult(False, NO_DIRECTORY_ENTRY, "no readable directory entry") + e = conn.entries[0] + + def one(attr: str) -> str: + v = getattr(e, attr, None) + return str(v.value) if v is not None and v.value else "" + + if group: + try: + if not member_of(conn, sam, group): + log.info("bind succeeded for %r but the account is not in %r", + sam, group) + return LdapResult(False, NOT_IN_GROUP, f"not in {group}") + except LookupError: + return LdapResult(False, GROUP_NOT_FOUND, f"group {group!r} not found") + + return LdapResult( + True, OK, + sam=one("sAMAccountName") or sam, + mail=one("mail"), + full_name=one("displayName"), + upn=one("userPrincipalName"), + ) + + except LDAPCertificateError as exc: + # NOT retried and NOT downgraded. Either the CA bundle is wrong or + # something is impersonating a DC; both need a human, and retrying with + # relaxed validation is exactly the wrong instinct. + log.error("LDAPS certificate validation FAILED against %s: %s. Refusing " + "to continue — check LDAP_CA_FILE, and never set CERT_NONE.", + HOST, exc) + return LdapResult(False, UNTRUSTED, str(exc)) + except (LDAPSocketOpenError, LDAPSessionTerminatedByServerError) as exc: + last_network_error = str(exc) + log.warning("LDAPS connect to %s:%s failed (attempt %d): %s", + HOST, PORT, attempt, exc) + continue + except LDAPException as exc: + log.error("LDAP error for %r: %s", sam, exc) + return LdapResult(False, UNREACHABLE, str(exc)) + finally: + if conn is not None: + try: + conn.unbind() + except Exception: + pass + + return LdapResult(False, UNREACHABLE, + last_network_error or f"no domain controller answered on {HOST}:{PORT}") + + +def selftest() -> LdapResult: + """Open a TLS session to the domain and validate the certificate, WITHOUT binding. + + Used by the startup check and by the admin console's diagnostics. Touches no + account, so it cannot contribute to a lockout. Proves the three things that + actually break a deploy: DNS resolves, a DC answers on 636, and the presented + certificate validates against our CA bundle. + """ + if not is_configured(): + return LdapResult(False, UNCONFIGURED, describe()) + conn = None + try: + conn = Connection(_server(), receive_timeout=TIMEOUT_SECONDS, raise_exceptions=True) + conn.open() + return LdapResult(True, OK, f"ldaps://{HOST}:{PORT} certificate validates") + except LDAPCertificateError as exc: + return LdapResult(False, UNTRUSTED, str(exc)) + except LDAPException as exc: + return LdapResult(False, UNREACHABLE, str(exc)) + finally: + if conn is not None: + try: + conn.unbind() + except Exception: + pass diff --git a/server/requirements.txt b/server/requirements.txt index 28ac028..eb6f57e 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -19,4 +19,8 @@ pydantic==2.13.4 python-dotenv==1.2.2 bcrypt==5.0.0 # password hashing PyJWT==2.13.0 # signed session tokens +ldap3==2.9.1 # D13: LDAPS simple bind against prime.local. Pure Python, + # so no system libldap/OpenLDAP headers in the image. The + # trust anchor is server/certs/prime-ca-chain.pem, NOT the + # system store — see server/ldap_auth.py. starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)