24fff382c8d80e72fd2bf0af067e10b0f5c319b9
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 24fff382c8 |
T10.7 - cover the throttle and local is_active; tick what was verified
Two things, both found by checking the wave file before pushing rather than assuming it was current. First: most done-when boxes were still open even where the work had been verified, which would have told a reviewer that almost nothing was checked. Ticked the ones genuinely verified, each with what verified it, and left eight open that are not. One box was not merely unticked but WRONG - it asked that `create-admin` create an admin with no password prompt, and T10.9 removed that command outright; restated as what now has to be true. Second: two of the open boxes were safety-relevant and cheap to close, so ldap_auth_check now covers them (24/24): a disabled local account is refused 403 even though its bind succeeds - local is_active overrides the directory, which is how access to THIS app is revoked without touching the domain account the throttle stops CALLING the directory, not merely refusing. Proved by spending the attempt budget on wrong passwords and then presenting the CORRECT one: a 429 for a credential that would otherwise succeed is only possible if the check runs before the directory is consulted. Also asserts the budget is per-username, so throttling one account does not throttle everyone. That was the last untested safety-critical behaviour on the branch. It is the thing standing between an unauthenticated caller and locking colleagues out of Windows, and until now nothing exercised it. Also recorded a deployment fact at the top of wave-10 where it cannot be missed: LDAP_REQUIRED_GROUP is empty, and empty means no group gate - every account in prime.local may sign in. The live sign-in that confirmed this branch works was made without it, so it proved the bind, the certificate chain and provisioning, but not the group check. That path has still never run against the real directory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| d261024139 |
T10.6 - before/after login screenshots at 390px and 1440px
Closes the last CLAUDE.md verification box on T10.6, which requires any task
touching the frontend to be exercised at both widths with screenshots in the PR.
Captured with the repo's own tests/baseline_shots.py rather than by hand; it
already treats login as the one page whose real state is signed out. "Before"
was shot from a detached git worktree at main (
|
|||
| 79787b3e9f |
T10.7 - full suite result recorded: 1284/1288 across 40 files
Ticks the last open box on T10.7. Two files fail and both were already logged
as pre-existing, with git diff main...HEAD showing this branch touches neither:
BL-028 (assets_check, whose "no MICRON_DB_URL" premise is violated by any
machine that has the var set) and BL-029 (generalinfo_check, flagging an rgba
literal last touched by
|
|||
| e0008670fc |
T10.5 closed, not built - the required group stays an env var
Proposed, examined, rejected. The wave file now records why at the task, so
nobody reads the original and assumes it was merely skipped.
Three honest points, in order of weight:
The console requirement was invented in the wave file, not asked for. D13
criterion 3 says "An AD group is configured" - not "configurable from the
console". LDAP_REQUIRED_GROUP satisfies the criterion as written.
The validate-on-save guard existed only to defend against a risk the console
itself introduced. A feature whose complexity exists to defend against itself
is usually the wrong feature.
And the lockout it defended against is already handled. Verified rather than
asserted: a group that does not resolve raises LookupError in member_of, which
verify() maps to GROUP_NOT_FOUND, which is_config_problem classifies as ours -
so login() answers 503 and the log reads "required group 'X' does not resolve
in DC=prime,DC=local - refusing the sign-in. This is a configuration fault, not
a bad password." A genuine non-member still gets 401. Already distinguishable.
Accepted losses, both recorded: an app admin cannot see the required group
without Portainer or shell access (a read-only diagnostic line was offered and
declined as unnecessary), and nothing validates the group until the first
sign-in attempt - which is unfixable, since resolving a group needs an
authenticated search, anonymous bind is disabled here, and there is no service
account by design.
Also logged two full-suite failures as backlog rather than letting them read as
D13 fallout. Both proven pre-existing: git diff main...HEAD shows neither file
touched by this branch.
BL-028 assets_check asserts "no MICRON_DB_URL" but start_server passes the
ambient environment through, so it fails on any machine whose .env
sets it. Same class of bug T10.7 fixed for LDAP_REQUIRED_GROUP.
BL-029 generalinfo_check flags rgba(20,30,50,.18) in the creator stylesheet,
last touched by
|
|||
| c47b2ae210 |
T10.7 D13 - the suite runs without a domain controller
Far smaller than estimated, because the premise was wrong. I had said four checks sign in and would each need a fake directory. They do not: seed() mints a session token with auth.create_token() and sets the cookie directly - browser_check's own docstring says so - and the only breakage was a leftover password_hash= kwarg on a model that no longer has the column. Deleting that one line in browser_check.seed() unblocked 39 files that import seed/start_server from it. launcher_check needed the same. console_dialogs_check's password-reset half is deleted rather than ported. Its docstring now records what went and where the prompt kit is still covered (wpPromptDialog has five callers left in wp-creation-app.js; creator_dialogs_check exercises them, validation included - verified, 20/20). Nothing was left skipped in place of the removed section. Exactly one check genuinely needed a seam: url_state_check drives the real login form to prove a deep link's ?next= survives authentication. That cannot be faked by minting a cookie, because the login round trip is the thing under test. The seam is env-driven because it has to be: start_server launches the app as a SUBPROCESS, so a monkeypatch in the test process would never reach the code doing the authenticating. server/ldap_fake.py reads WP_LDAP_FAKE_DIRECTORY and ldap_auth dispatches to it AFTER the empty-input guard, so the anonymous-bind guard covers the fake path too - a fake that reimplemented it would let the real one rot unnoticed. The production guard is the point of that module. An env var that makes any password work is exactly the kind of thing that escapes into production, and D13 left no other way in. is_active() refuses whenever a non-SQLite DATABASE_URL is configured - the same test auth._load_secret uses - and describe() shouts in capitals so a fake run can never be mistaken for a real one in the startup log. Two things found on the way, neither of them the app's fault: - url_state_check's "signing in continues to the requested page" asserted `"wp-creation-index.html" in location.href`. That string is in the ?next= parameter too, so it passed while sitting on login.html with the sign-in rejected. It would have passed with login entirely broken. Tightened to assert we actually left the login page. - Two assertions in my own new ldap_auth_check read the WRONG database: server/db.py binds its engine from DATABASE_URL at import, so setting the env var afterwards keeps reading whichever file was configured first. users_in() now opens the file it is asked about with sqlite3. The CERT_NONE check also had to become an AST walk - the module docstring names validate=ssl.CERT_NONE in order to explain why it is banned, and a text search cannot tell that apart from a real call. tests/ldap_auth_check.py is new coverage rather than repair: the anonymous-bind guard, CERT_REQUIRED by AST, the nested matching rule in the filter, the production refusal, a refused sign-in creating no account, and an existing admin still being an admin with their locally-set name intact. 20/20. Run so far, all green: browser_check 71/71, launcher_check 58/58, console_dialogs 12/12, url_state 23/23, qa_gate 41/41, critical_reopen 11/11, creator_dialogs 20/20, a11y 22/22, kitting_notify 17/17, ldap_auth 20/20. A full sweep of the remaining ~30 is running; its box stays unticked until it reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| dc0cee240e |
T10.8 D13/D14 - documentation matches what the code now does
DEPLOY-login-portal.md was the most wrong and is rewritten. It described taking a username/password portal live - bcrypt, and a first admin created with `create-admin --password`. Every command in it now fails. It keeps its filename and carries a note saying what it replaced, because an admin holding the old copy needs to know why the steps stopped working rather than concluding the deploy is broken. New content leads with the warning that there is no break-glass, and puts verification BEFORE announcing the deploy - the log line, the certificate check that binds nothing, then a real sign-in. DEPLOYMENT.md: AUTH_RESET_* replaced with the LDAP variables; the users table row no longer claims a password_hash column; "Self-service password reset" replaced by a section saying there isn't one and pointing at Okta. New "Domain authentication" section covering the three things that are not obvious - why prime.local and never a DC or an IP, why the CA bundle is not a certificate issued to this app (with the thumbprints and a Get-ChildItem line to rebuild it), and why the outbound network stopped being optional - plus the lockout arithmetic written out so the next person to raise AUTH_MAX_ATTEMPTS sees the constraint rather than a magic 2. server/README.md: endpoint table drops /api/auth/password and gains the role route; the login-portal section becomes domain authentication; create-admin becomes the two-step bootstrap (sign in, then promote). CLAUDE.md: a new "authentication rules" section beside the token rule, for the same reason that one exists - four things that look like tidying-up if you do not know why. The empty-password guard that must run before bind(), CERT_REQUIRED with an explicit CA file, AUTH_MAX_ATTEMPTS being arithmetic rather than taste, and connecting to the domain name rather than a DC. Plus: no break-glass, and roles are local - never read a role from AD. Closed three done-when boxes that were open rather than ticked: T10.8 all of them T10.9 promote/demote verified against a real bind (Aug 24), not a stub T10.3 the Postgres round trip, on postgres:16-alpine Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| c5540ce6da |
T10.9 D14 - the CLI authenticates against the domain; create-admin/create removed
Accounts are not created here any more. D13 provisions them on first successful
sign-in, so create-admin and create were redundant - and worse than redundant,
because a hand-typed username can end up matching no directory identity at all.
Removing them means every row now originates from a bind, which closes that
class of problem for everything except the rows the old CLI already made.
promote and demote replace them. Bootstrapping the first admin is now two steps
in order: sign in once, which provisions the account at project_user, then
promote your own sAMAccountName.
Every state-changing command requires a prompted domain bind. No --password
flag on anything, deliberately: that would put a live domain password into shell
history and into ps output for every other user on the box. `list` needs no
credential so an outage stays diagnosable.
Two deliberate divergences from the API, both commented at the code:
- The bind does NOT apply the login group gate. If a mistyped required group
locks everyone out of the console, this tool must still work, or the only
route to fixing the lockout is the thing the lockout prevents.
- Changing your OWN role is permitted. set_user_role in app.py forbids it to
stop an admin locking themselves out of the console; here it is the entire
bootstrap path. Allowed, and recorded with {"self": true}.
Kept from set_user_role: the last-admin guard, and clearing auto_add_projects
on promotion to admin (an admin already reaches every project, so the flag
would sit there invisible and spring back on demotion).
What this is worth, said plainly in the module docstring rather than implied:
anyone with a shell here can still write to the users table with psql or
sqlite3, so the bind is defence in depth and mostly ACCOUNTABILITY. Before
this, every role change from a shell was invisible in AuditLog while the same
change through the console was recorded. Now both are recorded and both name a
person. Any-domain-user was accepted as sufficient knowing that.
Verified: the three removed commands are rejected as invalid choices; list runs
with no credential; a state-changing command with LDAP misconfigured refuses
rather than proceeding unauthenticated; promote, demote, self-promotion, the
last-admin guard, the unknown-account message, and one audit row per change all
behave, with the bind stubbed.
Left open rather than ticked: none of this has been run against a real bind.
authenticate_operator was stubbed for the logic tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|||
| ab7bce9f5b |
T10.6 D13 - strip the password UI; "Forgot password?" goes to Okta
login.html/login.js: the two reset views are gone along with the reset-token handling, and the sign-in form now says which password to type - "your Windows password, the same one you use to sign in to your computer" - using the .hint class the page already had, so no new CSS and no new literal. "Forgot password?" is KEPT and points at https://primecontrols.okta.com/. An earlier draft of this task deleted the link and I proposed a plain "contact IT" sentence instead; Okta is the better answer, and with no app password and no break-glass it is the only recovery path that exists. Three details that would each have broken it: - The old click handler on #forgot-link called preventDefault() to swap views. Left in place it would have silently swallowed the navigation, so the link would look right and do nothing. There is now deliberately no handler, and login.js says why so nobody adds one back. - target="_blank" without rel="noopener noreferrer" hands the opened page a window.opener handle back to the login page. - Worth recording since it was checked rather than assumed: the CSP allows this. form-action 'self' governs form submission, not link navigation, and no navigate-to directive is set - so a plain <a href> off-origin is fine and the nginx config needs no change. login.js also handles 503 distinctly now. T10.2 made that mean "the directory is unreachable or misconfigured", which is our fault - showing "invalid password" would send people hunting for a password they no longer have while a deploy is broken. Also removed, because T10.3 deleted the endpoints behind them and leaving them would have produced visible 404s rather than dead-but-harmless markup: auth-guard.js the whole change-password dialog (POST /api/auth/password) wp-sidenav.js the "Password / Change your password" menu entry that opened it users.js the per-row "Reset password" action users.js the password field in the create-account form - NewUserIn no users.html longer accepts one, so the form was posting a rejected field The self-row placeholder button pointed at a top-bar Password link that no longer exists; it is now a plain "you" marker. Verified: node --check passes on all four touched JS files; the only password references left in html/ are the sign-in form and the SMTP config in admin.js, which is unrelated and stays. Logged BL-027 rather than acted on: the Okta URL is the first sign of an Okta tenant on this estate, which means an OIDC flow is available in principle and would remove the domain-lockout hazard that forced AUTH_MAX_ATTEMPTS to 2. D13 was decided and reaffirmed and T10.1-T10.4 are built, so swapping the mechanism mid-wave is the reordering CLAUDE.md forbids. Recording is not reopening. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
|||
| 0de746bc62 |
T10.4 D13 - provision on first sign-in without trampling existing accounts
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> |
|||
| 0577660c86 |
T10.1 D13 - the LDAPS client, verified against the live domain
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> |
|||
| 3c9343dfc8 |
D13 / wave 10 - the decision and the task file, before any code
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> |