Criteria 2 and 4 pull against each other: create accounts that do not exist,
never touch the role of accounts that do. Two helpers in app.py keep the two
cases apart so the role-preserving branch cannot be edited by accident.
Matching uses sAMAccountName OR the directory's mail, per the Aug 21 decision.
A bind can only carry one identifier, so the bind is sAMAccountName@prime.local,
but matching an existing local row tries both - existing accounts were typed by
hand with manage_users.py and some are short logon names while others are email
addresses. auth.find_user already compares case-insensitively against username
AND email, so two calls cover four columns.
Verified against a throwaway SQLite database:
existing admin -> role still 'admin'
locally-set full_name -> preserved, not overwritten by the directory
empty email -> filled from the directory
local username is email -> matched by mail, project_admin kept
no local row -> created at project_user, is_active, audit row
ProjectMember rows -> 0
second sign-in -> same row, no duplicate, 3 users total
A JIT account deliberately gets NO project access. The wave file said to honour
the auto_add_projects machinery so a new account "lands in the right projects";
that was wrong about the flag, which is evaluated when a PROJECT is created to
mark who joins every new job and cannot retroactively add an account to jobs
that already exist. There is no correct default, so least privilege applies and
the wave file's done-when has been corrected rather than quietly satisfied.
The consequence is a UX cliff worth knowing about: a successful sign-in into an
empty app until an admin grants access. That is why provisioning writes an
AuditLog row and a log line instead of happening silently.
is_active is checked after provisioning (a new account defaults active) and
after the role branch (a disabled admin is still refused). Local is_active
overrides the directory on purpose: disabling here revokes access to this app
without touching the domain account.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
server/ldap_auth.py: simple bind to ldaps://prime.local:636 as
sAMAccountName@prime.local, nested-group membership via the
LDAP_MATCHING_RULE_IN_CHAIN extensible match, and a selftest() that validates
the DC certificate without binding so it can never contribute to a lockout.
Verified against the live domain, not just reasoned about:
selftest() to prime.local -> ok, "certificate validates"
selftest() to 192.168.3.37 -> refused, untrusted (no IP SAN)
empty / whitespace password -> empty_input, with Connection nulled out
so any call to bind() would have raised
missing CA file -> unconfigured, is_config_problem=True
Tls.validate -> ssl.CERT_REQUIRED, explicit ca_certs_file
Three things here are load-bearing and commented as such at the call site:
- The empty-password guard runs BEFORE bind(). An LDAP simple bind with an
empty password is an anonymous bind and it SUCCEEDS, so without the guard a
blank password authenticates as whatever username was submitted.
- No `version=` pin on Tls. An earlier draft of this file pinned
PROTOCOL_TLSv1_2, which would have silently downgraded every connection from
the TLS 1.3 these DCs actually negotiate.
- Retries cover connect failures only. A rejected credential returns
immediately, because every failed bind counts against the domain lockout
policy and this endpoint must not become a way to lock people out of Windows.
The trust anchor is server/certs/prime-ca-chain.pem - PRIME CONTROLS ROOT CA
plus ISSUING CA 1, public certificates with no private key, checked in because
they are public and long-lived (2051 / 2036). The system trust store is
deliberately not used: it currently trusts five other self-signed CAs on this
estate. LDAP_CA_FILE overrides the path for a mounted bundle.
docker-compose.yml: the `outbound` network is no longer optional. Its comment
said to detach it if you were not using the Micron asset picker; doing that
now breaks every sign-in, since `internal` has no default gateway and
therefore no route to prime.local:636.
Not yet verified, and called out rather than assumed: the nested-group case
needs a real group with a nested member, and the in-container
`openssl s_client -CAfile` check needs the stack. Both are T10.1 done-when
boxes still open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Authentication moves to the domain over LDAPS. Nothing in the spec covered
auth against a directory, so per CLAUDE.md this is new scope with a new `D`
id rather than a widened old one. D1-D12 were taken.
Records what was verified against the live environment on Aug 21 rather than
assumed: LDAPS on 636 with TLS 1.3, the DC cert chain up through PRIME
CONTROLS ISSUING CA 1 to a root valid until 2051, six DCs in the SRV record,
and `prime.local` in every DC cert's SAN - which is why the client connects
to the domain name and not to a DC or an IP.
Also records why the certificate already serving the site is not usable for
this: it is a Let's Encrypt DV cert held by an OpenResty host outside this
repo, and it carries no relationship to prime.local. The question was asked
directly and the answer is not obvious, so it belongs in the record.
Eight tasks, T10.1 through T10.8. Two questions are left open in the decision
doc to be answered rather than guessed - break-glass access when the DC is
unreachable, and whether existing usernames match sAMAccountName.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>