Compare commits

..

24 Commits

Author SHA1 Message Date
c99666796a DEPLOYMENT: a pre-deploy backup sequence for the D13 auth change
Asked whether there is an easy way to take a full backup before deploying,
since this touches auth. There is - the backup sidecar is already running and
scripts/db-backup.sh takes a one-shot dump - but three things about THIS deploy
were not written down anywhere.

Timing. The container starts with `alembic upgrade head && exec gunicorn`, so
the migration runs seconds after redeploy and there is no window afterwards. The
dump has to be taken before, not after.

Retention. The scheduled job prunes to the newest BACKUP_KEEP (14) files
matching wpsuite-*.sql.gz*, so on a daily cadence a pre-deploy dump is deleted
in a fortnight - exactly when a slow-burning problem would surface. Copying it
to a name outside the glob protects it.

Rollback is not just the database. The new code has no password_hash in its
model and the old code requires it, so restoring without also rolling the code
back leaves schema and application disagreeing. Recorded as both steps in
order, with a note to capture the current commit FIRST, since that is easy to
forget and impossible to reconstruct afterwards.

Also flagged what this particular dump is: the last copy of every password hash
that will ever exist. BACKUP_ENC_PASSPHRASE must be set before taking it - the
script warns and writes plaintext otherwise - and its retention deserves a
deliberate decision rather than the default fortnight, because bcrypt is not
plaintext but is crackable offline given a copy and time.

And a step to prove the dump is readable before deploying, because an untested
dump is not a backup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:55:33 -05:00
60b5b0c1f2 DEPLOYMENT: the Portainer variable list was missing two entries
Asked whether MICRON_DB_URL could live in Portainer rather than .env. It can -
for a Git-based stack it is the only route, since Portainer does not read a
local .env and every ${VAR} in the compose file resolves from the stack's
Environment variables.

But the note listing what to set there named POSTGRES_*, AUTH_SECRET_KEY,
BACKUP_ENC_PASSPHRASE and SMTP_PASSWORD only. MICRON_DB_URL was missing and has
been since it was added, and LDAP_REQUIRED_GROUP was missing because I added the
variable and never updated this list. That list is what someone follows when
standing the stack up.

Replaced with a table of every variable the compose file references, and what an
empty one actually costs. LDAP_REQUIRED_GROUP is the one worth reading twice:
unset means no group gate, so every account in the domain may sign in, and it is
SILENT - sign-in works, nothing looks wrong. That was observed first-hand today,
where a group had been configured, sign-in succeeded, and the group check had
never run.

Two encoding rules that pull in opposite directions, now stated together because
getting them the wrong way round is easy: MICRON_DB_URL is a connection URL and
must be percent-encoded; LDAP_REQUIRED_GROUP is a distinguished name and must NOT
be - its spaces and commas are legal as they stand. Neither takes quotes; a form
field is not a shell, and quotes become part of the value.

Also corrected the closing line, which claimed those were "the only credentials
in the system" while omitting the password embedded in MICRON_DB_URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:51:47 -05:00
c6a100405d T10.8 - document that a pre-D13 local SQLite database rejects new sign-ins
Found while diagnosing a login failure that turned out to be an account
lockout. The local wpsuite.db has no alembic_version table - it was built by
create_all() before D13 - so users.password_hash is still NOT NULL with no
default while the current model has no such column. Verified on a COPY of the
database rather than reasoned about: provisioning a new account raises

    IntegrityError: NOT NULL constraint failed: users.password_hash

The shape of it is what makes it worth documenting. Accounts already in the file
keep working, so the developer can sign in and nothing looks wrong; it breaks
only when a NEW person signs in, and it surfaces as HTTP 500, which reads as a
server fault rather than a schema one. Nothing anywhere told a developer their
existing database needed migrating.

The note gives the non-destructive fix - stamp a1b8c6d4e2f9 then upgrade head,
which runs only the drop and keeps the data - and says why a plain
`alembic upgrade head` would fail on such a file. Earlier in the session I
suggested deleting the database; stamping is strictly better, since deleting
discards test data for no benefit.

Production is unaffected: Postgres, migrations applied at container start.

Also updated the nested-group done-when to reflect what is now actually known.
The transitive matching rule WAS exercised against the live directory today for
a direct member of a 2,003-member group and returned a match, so the rule and
the filter are right on this estate. A genuinely nested case remains untested
for want of such an account, and the box is marked partial rather than done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:44:16 -05:00
25bc5bcd3f member_of could not tell a failed search from a non-member
Reported by a user who is a direct member of the configured group and was being
refused with "Invalid username or password". Get-ADUser confirmed the
membership, so the fault was here.

The connection is built with raise_exceptions=False. A search that FAILS
therefore returns False and leaves conn.entries empty - which is
indistinguishable from "no match" if you only inspect conn.entries, which is
all member_of did. Every possible failure of the extensible-match filter
presented to the user as "you are not in the group" while they plainly were,
and produced no log line saying otherwise.

Now:

- conn.search()'s return value is checked. A failed search raises
  LookupError(GROUP_CHECK_FAILED) and logs conn.result together with the filter
  that produced it. GROUP_CHECK_FAILED counts as a config problem, so login()
  answers 503 rather than 401 - our fault, not the user's, and reported as such.

- If the transitive query matches nothing, a plain memberOf equality check runs
  for DIRECT membership. If THAT matches, the person is a member and is let in:
  refusing a real member is the worse error. It logs a WARNING naming the
  matching rule, because that outcome means nested membership is silently not
  working on this connection and needs a human.

The failure mode this replaces is the one that hurts most: correct
configuration, correct credential, real membership, and a refusal that blames
the password. Same shape as the two logging faults fixed just before it - the
information existed and could not be read.

ldap_auth_check still 32/32; the fake directory exercises both branches.

A step-by-step diagnostic (bind, account lookup, memberOf dump, the nested
query, the direct query, group resolution) is in the session scratchpad rather
than the repo - it is a one-off aid, not a deliverable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 11:37:59 -05:00
b97ccd7ad8 Fix test env isolation: a popped variable comes back from .env
Caught the moment a real LDAP_REQUIRED_GROUP landed in .env: ldap_auth_check's
"a just-provisioned account appears in the Admin console list" started failing,
consistently, with no code change that could explain it.

start() removed LDAP_REQUIRED_GROUP from the subprocess environment so the test
could run without a group gate. But the server imports server/db.py, which
calls load_dotenv(), and python-dotenv skips only keys ALREADY PRESENT in
os.environ - so a popped variable is helpfully restored from the developer's
.env inside the child process. The test was quietly running against the real
required group, the fake "outsider" account was refused by it, and the account
under test was never provisioned.

Setting the variable to an empty string fixes it: empty still counts as
present, so load_dotenv leaves it alone.

Fixed in both harnesses - ldap_auth_check and browser_check, the latter shared
by 39 files - and corrected the fix suggested in BL-028, which said to pop
MICRON_DB_URL and would therefore not have worked either.

Worth stating as a rule: in this repo a test cannot assume an environment
variable is ABSENT. .env re-supplies it in any subprocess. Force the value you
want; never remove it.

ldap_auth_check 32/32, browser_check re-run green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 10:09:03 -05:00
3f4cd7ac92 Make a refused sign-in visible in the log
The reason a sign-in was refused was logged at INFO, in app.py and in
ldap_auth. Nothing in this app configures the root logger, and uvicorn
configures only its own - so an INFO record from wpsuite.* reaches no handler
and is discarded. The message existed and could not be read, in exactly the
situation it was written for: someone cannot sign in and the operator needs to
know whether the credential was wrong, the account is outside the required
group, or the group does not resolve.

Raised to WARNING on the three refusal paths:

  app.py     "sign-in refused for 'x' (not_in_group: not in CN=...)"
  ldap_auth  "bind refused for 'x': 52e (bad password)"
  ldap_auth  "bind succeeded for 'x' but the account is NOT in 'CN=...'"

Left at INFO: provisioning an account, and normalising an address to a
sAMAccountName. Those are narrative, not diagnostic.

Config faults were already ERROR and were always visible, which is why the
503 path could be diagnosed and the 401 path could not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 10:07:17 -05:00
332b74e5de T10.2 - actually implement the startup line the docs send people to
A gap, not a refinement. T10.2's task text said the login change "must add
visibility: a startup log line stating whether LDAP is configured", and three
documents tell an operator to run

    docker compose logs api | grep -i "LDAP auth"

as the FIRST diagnostic when nobody can sign in. Nothing ever logged it. The
grep would have returned silence, during exactly the outage it was written for,
and silence reads as "the API never started" rather than "the API is fine and
the group is misconfigured".

Found while answering a question that the line exists to answer: a required
group had been added to .env, sign-in still worked, and there was no way to
tell whether the group had been checked or the value had simply not been picked
up. (It had not - the file was unsaved. Both readings were correct at the time
they were taken.)

Implemented as a FastAPI lifespan handler. It reports CONFIGURATION only and
opens no connection: startup must not be able to hang on an unreachable domain
controller, and a bind at boot would count against the AD lockout policy for
whatever account it used. ldap_auth.selftest() remains the reachability check -
it validates the certificate without binding, so it cannot contribute to a
lockout either.

Verified by booting the real app under uvicorn and reading the log:

  wpsuite.api: LDAP auth enabled - ldaps://prime.local:636, domain prime.local,
  CA .../prime-ca-chain.pem, required group: CN=Prime Employees,OU=Prime
  Distribution and Security Groups,DC=prime,DC=local

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 10:05:00 -05:00
eecd724e02 T10.7 - close four more done-when boxes offline; token_version was mis-specified
I had said all six remaining boxes needed the live environment. Four did not,
and saying so was lazy scoping. ldap_auth_check now covers them (32/32):

  a just-provisioned account appears in GET /api/auth/users, the request the
  Admin console makes - without which nobody could grant a JIT account access

  granting admin through POST /api/auth/users/{id}/role, the request users.js
  sends, with the role verified to have actually changed. D13 criterion 4 was
  until now only asserted for role PRESERVATION, never for role GRANTING.

  no AD error-49 sub-code appears in any response body, while _err49 does parse
  one out of a real AD message - the log gets the detail, the caller does not

  token_version invalidates a cookie already issued, and leaves other sessions
  alone

That last box was wrong as written. It asked to exercise token_version "by a
role change", and nothing in app.py bumps it on a role change - or on a
deactivation. Neither needs to: get_current_user re-reads the account from the
database every request, so both take effect on the next request regardless.
T10.3's note that "role changes and deactivation should bump it" described an
intention rather than the code, and I repeated it without checking.

What token_version actually is now: a mechanism whose only trigger is the bump
manage_users makes on disable, which is belt-and-braces since is_active already
refuses the request. It works, and it is tested - but nothing much triggers it.
Logged as BL-030 rather than resolved here, because whether to wire it to
something (a "sign out everywhere" control is the usual shape) or remove it is
a session-handling design question, not an auth-wave bug.

Two boxes remain open, and both genuinely need your environment: member_of
against a real NESTED group, and the in-container openssl certificate check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:51:58 -05:00
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>
2026-08-24 09:42:45 -05:00
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 (a8e28bf) so each half ran against
its own server - the old login page needs the forgot-password endpoints that no
longer exist here. The worktree is removed again; the working checkout was never
switched.

What the diff shows, at both widths:

  the two reset views are gone
  the password field gained a hint saying WHICH password to type
  "Forgot password?" stays, now pointing at Okta

The hint uses the .hint class login.html already had, so no CSS was added and
no literal with it. It wraps to two lines at both widths, sits between the
password field and the button, and nothing overflows or shifts the card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:35:20 -05:00
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 8efe624).

A third entry in the sweep, token_check rc=2, was my runner's mistake rather
than a failure: token_check.py is not a pass/fail member of the suite, it is a
capture/diff tool that requires --out or --compare and prints usage when run
bare. Recorded at the done-when so the next person sweeping tests/*_check.py
does not chase it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:31:43 -05:00
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 8efe624 (F6). Needs C4's rgba-alpha exception
          interpreted before anyone decides whether the literal or the check is
          the wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 09:27:07 -05:00
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>
2026-08-24 09:18:05 -05:00
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>
2026-08-24 08:26:31 -05:00
8d49fb9248 T10.9 fix - the CLI's auth failure said nothing useful
"Authentication failed." and no more. I had carried the generic-message
reasoning across from /api/auth/login, where one indistinguishable failure is
correct because an unauthenticated caller must not be able to enumerate
accounts. That reasoning does not transfer to a local CLI: the operator IS the
account holder, there is nobody to leak to, and withholding AD's own sub-code
just makes the failure undiagnosable. It cost a round trip to find that out.

Now prints the reason, the AD error-49 sub-code, the exact bind string
attempted, the server, and the password length - enough to tell apart a wrong
password (52e), a locked account (775), an expired one (532), and the case that
looks like a bad password but is not: 525, no such user, which means the BIND
NAME is wrong rather than the credential.

That last one matters here. The bind is <sAMAccountName>@LDAP_DOMAIN, and
LDAP_DOMAIN defaults to the AD DNS name (prime.local). If an estate's UPN
suffix is the mail domain instead, that string is not a valid bind name and AD
answers 525 - which reads as "bad password" to anyone not looking at sub-codes.
The hint says so, and says to set LDAP_DOMAIN to the UPN suffix.

The password is never echoed or logged; only its length, which distinguishes
"getpass read nothing" from "getpass read the wrong thing".

/api/auth/login is untouched. Its generic 401 is still correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:00:50 -05:00
495d87dd72 Fix T8.6 migration: boolean default that Postgres rejects outright
NOT part of D13. Found while testing T10.3's migration against a real Postgres,
which could not be reached because the chain dies two revisions earlier.

a1b8c6d4e2f9 (T8.6 / D6, 190144c, Aug 19) creates material_items.active as:

    sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('1'))

sa.text() emits raw SQL, so that is an integer literal, and Postgres refuses it:

    psycopg.errors.DatatypeMismatch: column "active" is of type boolean but
    default expression is of type integer

SQLite accepts 1 as a boolean without complaint, which is why this passed every
local test. The sibling migration e2a4c7d91b30 creates the identical column
correctly with sa.true() - so the two are inconsistent and the older one is
right. Fixed to match, which renders as `true` on both engines.

THIS IS VERY LIKELY THE PRODUCTION 502. The Dockerfile CMD is
`alembic upgrade head && exec gunicorn ...`, so a failed migration means
gunicorn is never reached: no API process, nginx cannot reach api:8000, and
every /api/ route returns 502 while the static site keeps serving normally.
That is exactly the observed symptom - login.html 200, /api/health 502 - and
this migration landed Aug 19, so the first deploy carrying it would be the
first to break. `docker compose logs api` should show the DatatypeMismatch
above.

Committed separately from the D13 work so it can be cherry-picked to main ahead
of this branch. It is a one-line fix to a shipped wave 8 migration and should
not wait for an auth wave to merge.

Verified on postgres:16-alpine (the image docker-compose uses): the full chain
from empty now reaches head.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:51:43 -05:00
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>
2026-08-21 15:40:56 -05:00
9f91e9e26b T10.3 fix - the drop migration was deleting every project membership
Found by actually seeding the pre-migration schema and rolling forward, rather
than by checking that the column disappeared. The column disappeared correctly;
project_members came back empty.

batch_alter_table emulates ALTER on SQLite by rebuilding the table - create a
new one, copy the rows, DROP the original, rename. server/alembic/env.py:22
imports the engine from server/db.py, which registers a connect listener
setting PRAGMA foreign_keys=ON, so that DROP TABLE cascaded through
project_members.user_id (ondelete="CASCADE") and took every membership row with
it. No error, nothing in the log, and the users table looked perfect
afterwards.

Production would have escaped it - Postgres does a real ALTER TABLE DROP COLUMN
and touches nothing else - so this was a local-dev and test-fixture data loss,
which is worse in one specific way: the tests CLAUDE.md requires run against a
throwaway SQLite database, so the suite would have been validating behaviour
against silently emptied membership tables.

Wrapping the batch in PRAGMA foreign_keys=OFF is not the fix: that pragma is a
no-op inside a transaction and alembic runs migrations in one. The rebuild is
simply unnecessary - SQLite has had native ALTER TABLE DROP COLUMN since 3.35
(2021), this runtime has 3.42, and Postgres has always had it. Plain
op.drop_column touches one table and cascades nowhere.

The reasoning is written into the migration's docstring as a DO NOT, because
batch_alter_table is the reflexive thing to reach for when a migration has to
work on SQLite and the failure is invisible.

Re-verified with memberships in the fixture:

  3/3 users survive, roles intact (admin still admin)
  2/2 project_members survive
  downgrade -1 -> column back, nullable; upgrade -> gone again

Also closed BL-026: notify.send_now removed. Nothing referenced it and its
docstring described itself entirely in terms of password resets. send_email,
which it wrapped, is untouched and still used by the outbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:36:44 -05:00
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>
2026-08-21 15:30:16 -05:00
8cfb4c1008 T10.3 D13 - drop users.password_hash and every path that touched it
The irreversible one. The suite no longer stores a credential of any kind.

Removed from app.py: /api/auth/forgot-password, /api/auth/reset-password,
/api/auth/reset-available, /api/auth/password, /api/auth/users/{id}/password,
the four password-bearing input models, the reset throttle and mail body, and
the password arguments to create_user. Removed from auth.py: hash_password,
verify_password, password_problem, MIN_PASSWORD_LEN, _COMMON_PASSWORDS,
create_reset_token, decode_reset_token, RESET_MINUTES, and the bcrypt import.
Removed from notify.py: the password_reset_enabled feature flag. Removed from
manage_users.py: the password prompt and the reset-password command.

token_version STAYS. Password changes no longer exist, but a role change or a
deactivation still has to invalidate sessions that are already issued.

/api/auth/users/{id}/role stays, which is D13 criterion 4 - granting admin to
an existing account must keep working, and it does.

Migration b7e4f1a20c93 uses batch_alter_table because SQLite has no DROP COLUMN
before 3.35 and local dev runs on SQLite while production runs on Postgres.
downgrade() recreates the column NULLABLE rather than NOT NULL as the baseline
declared it: there are no hashes to put back, and a NOT NULL column with no
server default refuses to add itself to a table with rows. The docstring says
plainly that the downgrade does not restore the old login - it exists so the
revision is well-formed, not because stepping back is a recovery path.

Verified:

  upgrade head from empty      -> password_hash absent from users
  downgrade -1                 -> column back, nullable (notnull=0)
  upgrade head again           -> absent again
  remaining /api/auth routes   -> no password or reset route left
  create-admin                 -> works with no password prompt
  grep for the removed symbols -> nothing outside the migration and one
                                  docstring that names the dropped column

NOT verified, and it is a done-when box left open rather than ticked: the
migration has only been round-tripped on SQLite. No Postgres is available here.
batch_alter_table takes the direct ALTER path on Postgres, which is the simpler
of the two, but "simpler" is not "tested".

notify.send_now is now orphaned - its only caller was forgot_password. Logged as
BL-026 rather than deleted in passing, because an immediate unqueued send is a
reasonable primitive to keep and that decision does not belong in an auth task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:26:43 -05:00
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>
2026-08-21 15:11:38 -05:00
2a3c4a83fa T10.2 D13 - login binds against the domain instead of comparing a hash
server/app.py: login() now calls ldap_auth.verify(). No password is read from
the database on this path any more; the column is still there and unused until
T10.3 drops it, which keeps this commit revertible on its own.

THE THROTTLE IS THE POINT OF THIS COMMIT, not the bind.

AUTH_MAX_ATTEMPTS defaulted to 5, which is exactly this estate's AD lockout
threshold. Failures are now domain binds, and the API runs 2 gunicorn workers,
so that default would have let up to 10 binds reach a DC - locking the account
out of Windows twice over before the app's own lockout engaged. Anyone could
have done it to anyone, unauthenticated, from the login form. Now 2, so the
worst case is 4 against a threshold of 5, with the arithmetic written down at
the constant so the next person to raise it sees the constraint.

The persistent counter on `users` cannot cover this alone: under D13 accounts
are created on first login, so a real domain account can be attacked before it
has a local row. Added a username-keyed pre-account window for exactly that gap
- keyed by username rather than client IP, because it is the domain account
being protected, not this endpoint's capacity, and IP rotation would walk
straight past an IP-keyed limit.

Both throttles are checked BEFORE the directory is touched.

503 is now distinct from 401. LDAP unconfigured, unreachable, untrusted, or a
required group that does not resolve are OUR faults and return 503, because
D13 left no password fallback: reporting "invalid username or password" would
send people hunting for a password they no longer have while the real problem
is a broken deploy. A rejected credential and a missing group membership both
stay a single generic 401 - the useful detail goes to the log, never the
response, so account existence is not disclosed.

Also added a logger to app.py, which had none. An earlier draft of this commit
called log.warning() in a module where `log` was undefined; compileall does not
catch a NameError, so it would have surfaced as a 500 on the first failed
sign-in. Named wpsuite.api to match wpsuite.auth / .ldap / .notify.

Verified: app imports, the route is registered, LOGIN_MAX_ATTEMPTS is 2.
An authenticated user with no local row is refused with 403 until T10.4 adds
provisioning - deliberately explicit rather than silently admitted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:09:00 -05:00
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>
2026-08-21 14:06:49 -05:00
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>
2026-08-21 13:54:01 -05:00
37 changed files with 2766 additions and 825 deletions

View File

@@ -70,6 +70,31 @@ Adding a raw hex value to a page stylesheet is a defect regardless of what the t
for. Four parallel token systems is what produced S5, and the `.field-hint` comment at
`work-package-suite-styles.css:336` is the bug that resulted. Do not recreate it.
## The authentication rules
Sign-in is an LDAPS bind against the domain (`D13`, `docs/waves/decisions-2026-08-21.md`).
Four things about it are load-bearing and look like tidying-up if you do not know why:
- **The empty-password guard in `ldap_auth.verify` runs before `bind()`.** An LDAP
simple bind with an empty password is an *anonymous* bind and it SUCCEEDS. Remove
that check and a blank password authenticates as any username submitted. It looks
redundant because `login()` checks too. Both stay.
- **`validate=ssl.CERT_REQUIRED` with an explicit CA file.** Never `CERT_NONE`, never
the system trust store (which trusts five other self-signed CAs on this estate).
`CERT_NONE` still encrypts, so it fails silently - what it loses is the ability to
tell a real DC from someone harvesting domain passwords.
- **`AUTH_MAX_ATTEMPTS` is 2, and that is arithmetic, not taste.** Failures are real
domain binds counting against the AD lockout policy (5 here), and 2 workers double
it: 2 x 2 = 4 < 5. Raising it, or adding a worker, makes `/api/auth/login` a way to
lock colleagues out of Windows.
- **Connect to `prime.local`, never a DC name or an IP.** Every DC certificate carries
the domain name in its SAN; an IP fails hostname validation, and the only way to
force it is to disable the check above.
There is **no break-glass account** - a misconfiguration locks out everyone including
admins. And roles are LOCAL: the directory supplies identity, this app supplies
authorization. Never read a role from AD.
## Accessibility is in scope
Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done:
@@ -90,10 +115,12 @@ A task is not done because the code is written. Every task file lists its own do
checks. In addition, for any task touching the frontend:
1. Run the app locally: `uvicorn server.app:app` against a throwaway SQLite database.
Signing in needs a domain credential now (D13) a local run reaches `prime.local`
from the host with no extra configuration. A container needs the `outbound` network.
2. Exercise the affected flow at **390px** and at **1440px**. Field View at 390px is the
gloved-hands surface and is where the worst rendering was found.
3. Capture before and after screenshots into the PR.
4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record).
4. Run the existing smoke test. It signs in, and so does `server/seed_demo.py` (S13, fixed at T1.6 - this line said otherwise until Aug 20 2026, a stale record). Since D13 both need a **domain** credential, and `WP_SMOKE_PASSWORD` is now a real Windows password - never put one on a command line.
If a done-when check cannot be verified, do not mark the task complete. Say which check
failed and why.

View File

@@ -1,103 +1,154 @@
# Deploy: Work Package Suite — login portal update
# Deploy: Work Package Suite — domain sign-in (D13)
Instructions for the **Portainer admin** to take the new secure login portal live.
Instructions for the **Portainer admin** to take domain authentication live.
No prior context needed.
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
**What changed:** the app now has a username/password login. Going live needs:
1. one new environment variable,
2. a **rebuild** of the stack (not just a restart), and
3. creating the first admin account.
> **This document replaced an earlier one.** Until Aug 24 2026 it described taking a
> **username/password login portal** live: bcrypt hashes, an `AUTH_SECRET_KEY`, and a
> first admin created with `manage_users create-admin <user> --password …`. All of
> that is gone. The app no longer stores a password of any kind, `create-admin` no
> longer exists, and following the old steps will fail at the first command. The
> superseded design is recorded in `docs/waves/decisions-2026-08-21.md` (D13).
**What changed:** signing in is now an **LDAPS bind against `prime.local`**. People
use their **Windows password**. The suite stores no credential, there is no password
reset, and accounts create themselves on first sign-in.
Going live needs:
1. two environment variables,
2. a **rebuild** of the stack (not just a restart),
3. one network check, and
4. promoting the first admin.
> **Why a rebuild (not a restart):** both the **nginx/webserver** and **api** images
> bake the code in at build time (`COPY html/` and `COPY server/` in their
> Dockerfiles). A plain restart will **not** pick up the new code — the images must
> be **rebuilt** from the latest `main`.
> bake the code in at build time (`COPY html/` and `COPY server/`). A plain restart
> will **not** pick up the new code — the images must be **rebuilt** from latest `main`.
---
## 1. Add an environment variable to the stack
## ⚠ Read this before you start
In the stack's **Environment variables** section, add:
**There is no break-glass account.** If the directory is unreachable, the CA bundle
path is wrong, or the required group is misconfigured, **nobody can sign in —
including you.** That was a deliberate decision, not an oversight. Recovery is to fix
the configuration and restart; there is no local password to fall back on.
So: do step 3 before you tell anyone the deploy is done.
---
## 1. Add environment variables to the stack
In the stack's **Environment variables** section:
| Name | Value | Notes |
|------|-------|-------|
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. |
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). |
| `AUTH_SECRET_KEY` | a long random string | **Required.** Unchanged — still signs the session cookies. Keep the existing value; changing it signs everyone out. |
| `LDAP_REQUIRED_GROUP` | `CN=Prime Employees,OU=Prime Distribution and Security Groups,DC=prime,DC=local` | AD group required to sign in. A full DN is best — it skips a directory lookup. Nested groups count. Leave empty to allow any domain account. |
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Unchanged. |
Generate the secret on the host with:
You do **not** need to set `LDAP_HOST`, `LDAP_DOMAIN` or `LDAP_CA_FILE`. Their
defaults are correct for this estate, and the CA bundle ships inside the image.
```bash
openssl rand -base64 48
```
**Do not point `LDAP_HOST` at a domain controller's name or at an IP address.** It is
set to `prime.local` on purpose: every DC's certificate carries that name in its SAN,
so the domain name both validates and load-balances across all six DCs. An IP fails
certificate validation outright, and the only way to force it through is to switch
validation off — which would let anyone on the network intercept **domain passwords**.
> If `AUTH_SECRET_KEY` is **not** set, the app still starts but falls back to a random
> per-process key — logins then reset on every restart and break across the 2 gunicorn
> workers. It must be set to a fixed value.
The existing database variables (`POSTGRES_*`) are unchanged.
`AUTH_RESET_MINUTES` and `AUTH_RESET_COOLDOWN_SECONDS` can be deleted if present.
They configured the password-reset email, which no longer exists.
---
## 2. Pull latest `main`, rebuild, and redeploy
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled**
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and
`api` images.
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install
automatically during the rebuild.
- The `users` table is created automatically on API startup — **no DB migration needed.**
- Pull the latest commit on `main` and redeploy **with image rebuild enabled**.
- The new Python dependency (`ldap3`) is in `requirements.txt` and installs during
the rebuild.
- A database migration drops the `users.password_hash` column. It runs automatically
at container start. **Every account, role and project membership is preserved**
it removes one column, not any rows.
---
## 3. Verify the containers
## 3. Verify BEFORE announcing it
- Confirm `wp_api` and the webserver container are both **running**.
- If `wp_api` fails to start, check its **Logs**. (A missing `AUTH_SECRET_KEY` only logs a
warning — it won't crash — but please confirm it's set.)
---
## 4. Create the first admin account
The login system needs one admin user in the production (Postgres) database. Open the
**`wp_api`** container's **Console** (`/bin/sh`) and run:
**a. Did the API start at all?**
```bash
python -m server.manage_users create-admin <username> --name "<Full Name>"
docker compose logs api | grep -i "LDAP auth"
```
It prompts for a password (minimum 8 characters) and prints `Created admin: <username>`.
You want:
Non-interactive alternative:
```
LDAP auth enabled — ldaps://prime.local:636, domain prime.local, CA /app/server/certs/prime-ca-chain.pem, required group: CN=Prime Employees,…
```
If it says `LDAP auth DISABLED`, stop — nobody will be able to sign in. The message
names the reason.
**b. Can the container actually reach a domain controller?** This opens a TLS session
and validates the certificate **without binding**, so it touches no account and
cannot contribute to any lockout:
```bash
python -m server.manage_users create-admin <username> --name "<Full Name>" --password "<password>"
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
```
Other CLI commands (run the same way): `list`, `create <user> --role user`,
`reset-password <user>`, `disable <user>`, `enable <user>`.
Want `Verify return code: 0 (ok)`. If you get a connection error, the `api` container
is missing the `outbound` network — `internal` has no default gateway and blocks the
LAN as well as the internet. If you get `62 (hostname mismatch)`, something is
pointing at an IP instead of `prime.local`.
**c. Sign in.** Use your own Windows username and password.
---
## 5. Confirm it works
## 4. Promote the first admin
1. Load the site's normal URL — it should redirect to a **login page**.
2. Sign in with the admin account from step 4.
3. That admin can then add all other users from the in-app **Admin → User
administration** page (top-right **Admin** link), so no further shell access is needed.
Roles are stored locally and are not read from AD, so someone has to be made an admin
once. Sign in first — that creates your account — then:
```bash
docker compose exec api python -m server.manage_users promote <your-sAMAccountName>
```
It asks for **your** domain username and password, binds to confirm who you are, and
prints `<user>: project_user -> admin`.
Other commands: `list` (needs no credential), `demote`, `disable`, `enable`.
`create-admin`, `create` and `reset-password` no longer exist.
After that, admins manage everyone else from the in-app **Admin → User
administration** page. No further shell access needed.
---
## What people will notice
- They sign in with their **Windows password**, not an app password.
- **"Forgot password?"** now goes to `https://primecontrols.okta.com/`. The app cannot
reset a password it does not hold.
- The **Change password** item is gone from the top-right menu.
- Anyone in the required group can sign in **without being added first** — their
account is created automatically. They will see **no projects** until an admin
grants access, which is intentional. New accounts appear in the Admin console and
each one is recorded in the audit log.
- Two wrong passwords and the app stops trying for a while. That is deliberate: every
failed attempt is a real domain bind and counts against the **AD lockout policy**,
so the app stops well short of locking anyone out of Windows.
## Reference — what's in this release
- `server/auth.py`bcrypt password hashing, JWT session cookie, the request gate.
- `server/app.py``/api/auth/*` endpoints + middleware that refuses every `/api` data
route without a valid session.
- `server/manage_users.py` — the CLI used in step 4.
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard.
- `html/admin.html` / `admin.js`Admin Console gated on the admin role, with the user
administration UI.
- Sessions are stateless: a signed JWT in an **HttpOnly, SameSite=Lax** cookie, marked
**Secure** automatically when served over HTTPS (via `X-Forwarded-Proto` from nginx).
- `server/ldap_auth.py`the LDAPS client: bind, nested-group check, certificate validation.
- `server/app.py``login()` binds instead of comparing a hash; password endpoints removed.
- `server/auth.py` — sessions and roles only; no hashing, no reset tokens.
- `server/certs/prime-ca-chain.pem` — the CA bundle that validates the DC certificate.
- `server/manage_users.py``promote` / `demote`, each requiring a domain bind.
- `html/login.html`, `login.js`one view; "Forgot password?" points at Okta.
- Migration `b7e4f1a20c93` — drops `users.password_hash`.

View File

@@ -69,10 +69,17 @@ BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# details.
# SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
# OPTIONAL — the AD group required to sign in (D13). A group NAME or a full DN;
# nested groups count. Empty means any domain account may sign in. This is the
# initial value; the live one is set in the Admin console.
# LDAP_REQUIRED_GROUP=CN=Prime Employees,OU=Prime Distribution and Security Groups,DC=prime,DC=local
#
# OPTIONAL — the rest of the directory settings. The defaults are correct for this
# estate and you should not normally set them. NEVER point LDAP_HOST at a DC name
# or an IP: see § Domain authentication below.
# LDAP_DOMAIN=prime.local
# LDAP_HOST=prime.local
# LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
```
The API builds its own DB connection string from the `POSTGRES_*`
@@ -84,13 +91,34 @@ ignored whenever the three `POSTGRES_*` values are present.
Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack these go in the stack's
> **Environment variables** section (Portainer doesn't read a local `.env`).
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
> **Portainer note:** for a Git-based stack the stack's **Environment variables**
> section is not merely an alternative to `.env` — it is the ONLY route, because
> Portainer does not read a local `.env` at all. Every value the compose file
> references as `${VAR}` has to be set there or it arrives empty.
>
> The full list, and what an empty one costs you:
>
> | Variable | Required? | If unset |
> |---|---|---|
> | `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` | **yes** | the stack will not start |
> | `AUTH_SECRET_KEY` | **yes** | compose fails fast; the API refuses to start |
> | `LDAP_REQUIRED_GROUP` | **effectively yes** | no group gate — **every account in the domain may sign in**. Silent: sign-in works, so nothing looks wrong. |
> | `BACKUP_ENC_PASSPHRASE` | before real data | dumps are written unencrypted |
> | `SMTP_PASSWORD` | only with email on | notifications are recorded and never sent |
> | `MICRON_DB_URL` | optional | the asset picker degrades to manual entry |
>
> Paste values raw — it is a form field, not a shell, so no surrounding quotes.
> Quotes are not stripped and become part of the value: a quoted
> `LDAP_REQUIRED_GROUP` will not resolve, and a quoted `MICRON_DB_URL` will not
> parse.
>
> **`MICRON_DB_URL` must be URL-encoded** (`@` → `%40`, `#` → `%23`, `/` → `%2F`)
> because it is a full connection URL. `LDAP_REQUIRED_GROUP` must NOT be encoded —
> it is an LDAP distinguished name, and its spaces and commas are legal as they are.
These are the only credentials in the system, and they never appear in the
compose file or in git.
`POSTGRES_PASSWORD`, `AUTH_SECRET_KEY`, `BACKUP_ENC_PASSPHRASE`, `SMTP_PASSWORD` and
the password inside `MICRON_DB_URL` are the only credentials in the system, and none
of them appears in the compose file or in git.
## 3. Point your reverse proxy at the nginx container
@@ -257,7 +285,7 @@ users on the same project see the same server-stored SOP and Work Packages.
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
| `users` | login accounts (no password — D13) | `username` (sAMAccountName), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
@@ -296,6 +324,56 @@ docker compose up -d --build webserver # front-end change (html/) — rebuild
docker compose up -d --build api # backend change (server/)
```
## Before deploying the D13 auth change — take a backup first
**Timing is the whole point.** The container's start command is
`alembic upgrade head && exec gunicorn`, so migrations run *seconds after you
redeploy*. Migration `b7e4f1a20c93` drops `users.password_hash`. There is no window
afterwards: back up **before** you redeploy, not after.
```bash
# 1. Record what you are rolling back TO. Do this first; it is easy to forget
# and impossible to reconstruct under pressure.
git -C /path/to/repo rev-parse --short HEAD
# 2. One-shot dump, using the sidecar that is already running.
docker compose exec backup sh /scripts/db-backup.sh
# -> ./backups/wpsuite-<UTC timestamp>.sql.gz[.enc] on the host
# 3. Take it OUT of the rotation. The scheduled job prunes to the newest
# BACKUP_KEEP (default 14) files matching wpsuite-*.sql.gz*, so on a daily
# cadence this dump is deleted in a fortnight. A prefix that does not match
# the glob is enough to protect it.
docker compose exec backup sh -c 'cd /backups && cp "$(ls -1t wpsuite-*.sql.gz* | head -1)" "pre-d13-$(ls -1t wpsuite-*.sql.gz* | head -1)"'
# 4. Prove it is readable BEFORE you deploy. An untested dump is not a backup.
docker compose exec backup sh -c 'openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE -in /backups/pre-d13-*.sql.gz.enc | gunzip -c | grep -c "INSERT INTO public.users"'
# (drop the openssl stage for an unencrypted .sql.gz)
```
### Two things about this particular dump
**It is the last copy of every password hash that will ever exist.** After the
migration the column is gone; this file is where those bcrypt hashes live from then
on. Make sure `BACKUP_ENC_PASSPHRASE` is set before step 2 — the script warns loudly
if it is not, and writes plaintext — and decide deliberately how long to keep the
file. bcrypt is not plaintext, but it is crackable offline given time and a copy.
**Restoring the database is not, by itself, a rollback.** The new code has no
`password_hash` in its model and the old code requires it, so a restore without a
matching code rollback leaves you with a schema and an application that disagree. A
real rollback is both, in this order:
```bash
# redeploy the commit from step 1 (Portainer: point the stack back and rebuild)
docker compose exec backup sh /scripts/db-restore.sh /backups/pre-d13-wpsuite-<ts>.sql.gz.enc
```
`db-restore.sh` dumps are taken with `--clean --if-exists`, so restoring **drops and
recreates** objects before loading. It overwrites whatever is currently there.
---
## Backups & retention
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
@@ -350,27 +428,96 @@ TLS / From address and flips the master toggle.
package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset
### Password reset — there isn't one
Turning email on also enables **Forgot password** on the login page. Until then the
link explains that an admin must reset it (`server/manage_users.py`, or the Admin
console's **Reset password** button).
D13 removed local passwords entirely. **Turning email on no longer affects sign-in.**
The login page's "Forgot password?" links to `https://primecontrols.okta.com/`, which
is the only self-service route; the app cannot reset a credential it does not hold.
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES`
(default 60). It is **single-use**: completing a reset bumps the account's
`token_version`, which both burns the link and signs out that user's other
sessions. A completed reset also clears any login lockout.
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead.
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120)
so the form can't be used to flood someone's inbox. The throttle is per worker
and in-memory; the token expiry is the real control.
- Reset mails are sent **immediately, not through the notifications outbox** — a
reset link must never be persisted where an admin could read it and take over an
account.
- Set `app_base_url` in the admin card, or the emailed link will be relative and
therefore useless.
Email still carries WP-assignment notifications and the critical-reopen mail.
---
## Domain authentication (D13)
Sign-in is an **LDAPS simple bind** as `<sAMAccountName>@prime.local`. There is no
password in the database and **no break-glass account**. If the domain is
unreachable, `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
**nobody can sign in, including admins.**
**First thing to check on any sign-in problem** — the API logs one line at startup
saying whether LDAP is configured, and `/api/health` stays unauthenticated so the
stack is diagnosable while nobody can log in:
```bash
docker compose logs api | grep -i "LDAP auth"
# LDAP auth enabled — ldaps://prime.local:636, domain prime.local, …
# LDAP auth DISABLED — CA bundle not found at '…'. No one can sign in.
curl https://wp-suite.company.local/api/health # → {"ok": true}
```
Then prove the certificate path, without binding — this touches no account and so
cannot contribute to a lockout:
```bash
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
# want: Verify return code: 0 (ok)
```
### Three things that are not obvious
**Connect to the domain name, never a DC or 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 published in `_ldap._tcp.prime.local`. An IP gives
`Verify return code: 62 (hostname mismatch)` because there is no IP SAN — and the
only way to force it through is to disable validation. Do not. Domain passwords
cross this link, and an unvalidated one can be terminated by anyone on the network
who then harvests them.
**The CA bundle is not a certificate issued to this app.** The API is the TLS
*client*; clients verify, they do not present. `server/certs/prime-ca-chain.pem`
contains `PRIME CONTROLS ROOT CA` (valid to 2051) and `PRIME CONTROLS ISSUING CA 1`
(2036) — public certificates with no private key. There is nothing to request from
IT, no CSR and no enrollment. Rebuild it from any domain-joined machine with:
```powershell
Get-ChildItem Cert:\LocalMachine\Root, Cert:\LocalMachine\CA |
Where-Object { $_.Thumbprint -in
'C371E91C430A12051029527C443B1EF683675CF3', # PRIME CONTROLS ROOT CA
'4F7506105228C73DF64181ACA20AD9783437EC8B' } # PRIME CONTROLS ISSUING CA 1
```
exporting each as Base-64 and concatenating them into one file.
**The `outbound` network is required.** `internal` has no default gateway, which
blocks the LAN and the VPN as well as the internet, so the `api` container cannot
reach `prime.local:636` without it. Its comment used to say it was optional if you
were not using the Micron asset picker; detaching it now breaks every sign-in.
### Accounts
Accounts are **created on first successful sign-in**, at `project_user` with **no
project access** — the person signs in and sees nothing until an admin grants it.
Roles are local and never read from AD, so an existing admin keeps admin.
The first admin is bootstrapped in two steps: sign in once, then
```bash
docker compose exec api python -m server.manage_users promote <sAMAccountName>
```
which prompts for *your* domain credential. `list`, `demote`, `disable` and `enable`
are the other commands; `create-admin` and `create` no longer exist.
### The lockout arithmetic
`AUTH_MAX_ATTEMPTS` defaults to **2**, and that is a safety limit rather than a
preference. Failures are now domain binds, so they count against the **AD account
lockout policy** (5 on this estate). The throttle is per-process and the API runs 2
gunicorn workers, so a local limit of N allows up to 2N binds to reach a DC: 2 × 2 = 4,
one under the threshold. **Raising this, or adding a worker, means redoing that
arithmetic** — otherwise `/api/auth/login` becomes a way for anyone, unauthenticated,
to lock a colleague out of Windows.
## Permissions roles

View File

@@ -89,6 +89,11 @@ be built as written, or cannot be built once, until something else lands.
| 7 | The creator | `docs/waves/wave-7.md` | `B7` `A1` `CR-015` `A2` `A6` `CR-014` `CR-007` `B6` `S1`(creator) `F6` `D1` `D2` `D3` `D4` `D5` `D8` `D9` `D10` |
| 8 | Kitting and material | `docs/waves/wave-8.md` | `CR-009` `CR-010` `CR-011` `CR-012` `CR-013` `D6` `D10` |
| 9 | Verification and cleanup | `docs/waves/wave-9.md` | `CR-008` `CR-017` `S6` `S7` `C1` `C2` `C4` `D7` |
| 10 | Domain authentication over LDAPS | `docs/waves/wave-10.md` | `D13` |
Wave 10 was added on August 21, 2026 and is not part of the original nine-wave sequence. It
is new scope (`docs/waves/decisions-2026-08-21.md`), not a reinterpretation of anything
above, and it depends only on wave 9 being merged rather than on any particular item in it.
**Waves 1 through 4 produce almost no field-visible change.** That is deliberate and it is
roughly the first third of the effort. It is called out here because the Micron team is

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -555,42 +555,97 @@ deliberately deferred.
- **Suggested wave or follow-up:** next housekeeping pass, with the check
widened so it cannot recur.
### BL-026 — No version stamp: "is live current?" cannot be answered from the app
### BL-026 — CLOSED 2026-08-21 (removed; nothing referenced it)
- **Found during:** the 2026-08-21 outage triage (the question that started it)
- **Where:** `Dockerfile` / build, `server/app.py` `/api/health`, admin console
- **What:** the app carries no record of what code it is running. `/api/health`
returns `{"ok": true}` and nothing identifies the deployed commit, so
answering "is the live site on the latest code?" took fingerprinting
(probing for files/routes that only exist after certain merges) in the
middle of an outage. The fix: bake the git SHA into the image at build time
(`ARG GIT_SHA`), return it from `/api/health`
(`{"ok": true, "version": "<sha>"}`), and show it on the admin console's
diagnostics card. Then currency is one glance against `git log -1`.
- **Why not now:** new scope — needs its own item id per the working rules
(D13 is the natural next), and it touches the image build, which deserves a
deploy alongside someone with host access.
- **Suggested wave or follow-up:** next housekeeping pass; ~1 task including a
probe check that /api/health carries a version field.
- **Found during:** `T10.3` (D13), stripping the password code paths
- **Where:** `server/notify.py`, `send_now()`
- **What:** `send_now` sends one message immediately, outside the outbox queue. Its
only caller was `forgot_password`, because a reset link must not sit in a queue.
`T10.3` deleted that endpoint, so the function now has no callers anywhere in
`server/` or `tests/` — verified by grep, not assumed.
- **Resolution:** deleted. Raised as a judgement call between "remove it" and "keep
it as the documented immediate-send path"; answered on Aug 21 — remove it. Nothing
in `server/` or `tests/` referenced it, and its docstring explained itself entirely
in terms of password resets, which no longer exist. Keeping an unused sender that
bypasses the outbox is a liability, not an asset: the next person to need immediate
mail should write it against the requirement they actually have.
- **Note:** `send_email` (the raw SMTP call it wrapped) is untouched and still used by
the outbox.
### BL-027 — Migrations are rehearsed on SQLite only; production is Postgres
### BL-027 — Okta exists on this estate; OIDC is a live alternative to the LDAPS bind
- **Found during:** the 2026-08-21 production outage (D6's `material_items`
migration crash-looped the api container)
- **Where:** `DEPLOYMENT.md` (the update/deploy steps), `tests/`
- **What:** the migration chain is verified end-to-end on scratch SQLite, but
production runs Postgres, and the dialects disagree exactly where it hurts:
`server_default=sa.text('1')` on a Boolean passed every SQLite rehearsal and
was refused by Postgres at deploy (DatatypeMismatch), taking the API down
until the table was created by hand. The hotfix (64eac0c) fixed that one
instance and pinned the Boolean-default class in `materials_check`; the
CLASS of dialect drift is still unguarded. Two cheap layers: (1) a runbook
step — render `alembic upgrade --sql` for the postgresql dialect and read it
before restarting (offline, needs no live DB; this render would have shown
`DEFAULT 1` on a boolean); (2) better, a probe that renders every migration
for the postgresql dialect on each run and fails on anything the dialect
rejects or on known-bad patterns.
- **Why not now:** the outage is resolved and the one known instance is fixed
and pinned; the systematic guard is its own small task, not a hotfix rider.
- **Suggested wave or follow-up:** next housekeeping pass, paired with BL-026
(both are "deploys should be boring" work).
- **Found during:** `T10.6` (D13), repointing "Forgot password?" at
`https://primecontrols.okta.com/`
- **Where:** authentication as a whole — `server/ldap_auth.py`, `server/app.py` `login()`
- **What:** D13 chose an LDAPS simple bind, decided before it was known that the company
runs an Okta tenant. Okta presumably federates to `prime.local` (which is why the
Windows password is still the one that binds), but its existence means an OIDC
authorization-code flow is available in principle. That would be strictly better on
three counts the LDAPS design cannot match: this app would never see a password at all,
MFA would come for free, and the domain-lockout hazard that forced
`AUTH_MAX_ATTEMPTS` down to 2 would disappear entirely, because failed attempts would
land on Okta rather than on a bind this endpoint makes.
- **Why not now:** D13 was decided and reaffirmed, T10.1T10.4 are built and verified
against the live domain, and swapping the mechanism mid-wave is exactly the reordering
`CLAUDE.md` forbids. Recording it is not the same as reopening it.
- **Suggested wave or follow-up:** its own item and its own decision, with Nick and
whoever administers the Okta tenant. Not a widening of D13.
### BL-028 — `assets_check` fails on any machine that has `MICRON_DB_URL` set
- **Found during:** `T10.7` (D13), running the full suite
- **Where:** `tests/assets_check.py`, the "no `MICRON_DB_URL`" case
- **What:** the check asserts `/api/assets` answers `configured:false` when the catalog
is not configured, but `start_server` passes the ambient environment through. On a
developer machine whose `.env` sets `MICRON_DB_URL` — which is the normal state for
anyone who has ever used the asset picker — the API is genuinely configured, returns
real Micron tags, and three checks fail. Nothing is wrong with the app; the test's
premise is violated by the environment it runs in.
- **Fix:** SET `MICRON_DB_URL` empty for that server — do not pop it. `server/db.py`
calls `load_dotenv()` at import, and python-dotenv only skips keys already present in
`os.environ`, so a *popped* variable is restored from the developer's `.env` inside the
subprocess and the test runs against the real catalog anyway. An empty string counts as
present and therefore wins. `start_server` does exactly this for `LDAP_REQUIRED_GROUP`
(`T10.7`), after the pop-based version was caught doing the wrong thing.
- **Why not now:** it is not this wave's defect and the fix belongs with whoever owns
the asset picker's tests. Recorded so the failure is not mistaken for D13 fallout.
- **Suggested wave or follow-up:** next housekeeping pass.
### BL-029 — `generalinfo_check` flags a pre-existing `rgba()` in the creator stylesheet
- **Found during:** `T10.7` (D13), running the full suite
- **Where:** `html/wp-creation-styles.css:929` — `box-shadow:0 8px 24px rgba(20,30,50,.18)`
- **What:** `generalinfo_check`'s token-rule check reports "no colour literal was added
to the creator's stylesheet" and fails on `rgba(`. The literal predates this wave —
last touched by `8efe624` (F6) — and `git diff main...HEAD` shows the file untouched
by the LDAPS branch.
- **The real question is which is wrong.** `C4`'s recorded exception allows rgba
**alphas** as opacity recipes, which is arguably what a shadow is; if so the check is
too strict and should match a colour literal rather than the `rgba(` token. If not,
the shadow needs a token. Either way it is a one-line change plus a decision, and
the decision is not this wave's to make.
- **Why not now:** drive-by fixes to the token system are what `CLAUDE.md` forbids, and
this one needs the C4 exception interpreted rather than guessed.
- **Suggested wave or follow-up:** next housekeeping pass, with `C4` re-read first.
### BL-030 — `token_version` is now near-vestigial; decide whether it earns its place
- **Found during:** `T10.7` (D13), closing the done-when that assumed a role change bumps it
- **Where:** `server/models.py` (`User.token_version`), `server/auth.py` (`create_token`,
`get_current_user`), `server/manage_users.py` (`_set_active`)
- **What:** `token_version` existed to invalidate live sessions when a password changed.
D13 removed passwords, and **nothing in `app.py` bumps it any more** — not
`set_user_role`, not `set_user_active`. Nor do they need to: `get_current_user` loads
the account from the database on every request, so a role change or a deactivation
takes effect on the next request regardless. `T10.3`'s note that "role changes and
deactivation should bump it" describes an intention, not the code.
- **Its one remaining trigger** is the bump added to `manage_users._set_active` in
`T10.9`, which is belt-and-braces rather than load-bearing — `is_active` alone already
refuses the request.
- **The question:** is there still a case for invalidating a live cookie *without* also
disabling the account? If yes, wire it to something (a "sign out everywhere" control is
the usual shape) and say so. If no, the column, the claim, and the check are three
places carrying a mechanism nothing triggers.
- **Why not now:** it is a design question about session handling, not an auth-wave bug,
and the mechanism works correctly — it is exercised in `ldap_auth_check`.
- **Suggested wave or follow-up:** next housekeeping pass.

View File

@@ -0,0 +1,210 @@
# Decisions — August 21, 2026
One item, and it is the largest single change to the auth model since the login portal
shipped. Like the August 18 and August 20 sets it is a **new item** with its own `D` id,
not a reinterpretation of an existing one. `D1``D12` are taken; this is `D13`.
Raised by Cody Schaefer on Aug 21 2026 while asking how the suite handles HTTPS. Nothing in
`IMPLEMENTATION.md`, in `CR`/`F`/`S`/`A`/`B`/`C`, or in `docs/waves/backlog.md` covers
authentication against the domain — so this is new scope, and it gets a new ID rather than
being folded into the login-portal work that produced `server/auth.py`.
---
## D13 — Authentication moves to the domain over LDAPS
- **Amends:** the authentication model shipped in `DEPLOY-login-portal.md` (bcrypt hashes in
`users.password_hash`, verified in-process). That document describes what is being
replaced, not what is wrong — it was correct for a suite with no directory behind it.
- **Surface:** `server/` (`auth.py`, `app.py`, `models.py`, `manage_users.py`, `notify.py`,
a new `ldap_auth.py`, a new migration), `html/` (`login.html`, `login.js`, `admin.js`,
`users.js`), `requirements.txt`, `docker-compose.yml`, `Dockerfile`, deployment docs.
- **Wave:** 10 (`docs/waves/wave-10.md`). Depends on wave 9 merged, which it is.
### The decision
The suite stops storing passwords. A sign-in becomes a **simple bind to
`ldaps://prime.local:636`** as `<username>@prime.local` using the password the person typed.
A successful bind is the authentication. `users.password_hash` is dropped from the schema.
Four parts, all four required for the item to be done:
1. **LDAPS bind replaces local password verification.** `password_hash` is removed from the
model and from the database by migration. No password is stored, hashed or otherwise.
2. **Accounts are provisioned just-in-time.** A successful bind for a username with no
`users` row creates one, at the default role, with `full_name`/`email` read from the
directory.
3. **A required group gates login.** An AD group is configured; a bind that succeeds but
whose account is not in that group is refused. Membership is evaluated including nested
groups.
4. **Existing accounts keep their roles, and roles stay local.** An existing `admin` stays
an admin on first directory login. Granting admin to an existing account continues to
work from the Admin console. The directory supplies *identity*; this app supplies
*authorization*.
### Why LDAPS and not the certificate already in play
Recorded because the question was asked directly and the answer is not obvious.
The site's serving certificate is a **Let's Encrypt** cert (`CN=wp.controls.dev`, issued by
`Let's Encrypt YE2`, expiring 2026-11-08) held by an **OpenResty** instance at
`192.168.3.56` that is not part of this repo. It is a public domain-validated certificate.
It attests that whoever presented it controls DNS for `wp.controls.dev`; it carries no user
identity and no relationship to `prime.local`. There is no configuration that turns it into
a domain credential, so cert-based auth was never available "for free".
Client-certificate auth (mTLS) was considered and rejected for this wave: TLS terminates two
hops upstream at OpenResty, so the API never sees the handshake, and doing it in-app would
mean bypassing the proxy and losing the CSP/HSTS headers and static serving with it.
### What was verified before writing this (Aug 21 2026)
| Fact | Value |
|---|---|
| LDAPS reachable | `192.168.3.37:636` open, TLS 1.3, `TLS_AES_256_GCM_SHA384` |
| DC cert issuer | `CN=PRIME CONTROLS ISSUING CA 1, DC=prime, DC=local` |
| Root of that chain | `CN=PRIME CONTROLS ROOT CA` (self-signed, expires 2051-09-09) |
| Issuing CA expiry | 2036-09-09 |
| DC cert SAN | `DR-DC10Core.prime.local`, `prime.local`, `PRIME` |
| DCs published in `_ldap._tcp.prime.local` | six — `nla-dc10`, `lew-dc20`, `dr-dc30-core`, `lew-dc40`, `SABINEDC`, `dr-dc10core` |
| Chain validates against root+issuing bundle | yes — `Verify return code: 0 (ok)` |
Two consequences of that table, both binding on the build:
- **Connect to `prime.local`, not to a DC name or 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. Verified: `prime.local` gives `0 (ok)`; the raw IP
`192.168.3.37` gives `62 (hostname mismatch)`, because there is no IP SAN.
- **The trust anchor is a CA certificate, not a certificate issued to this app.** The API is
the TLS *client*; clients present nothing. It needs `PRIME CONTROLS ROOT CA` plus
`PRIME CONTROLS ISSUING CA 1` as a PEM bundle, which is public information. No CSR, no
enrollment, no private key, nothing to request from IT.
### Non-negotiables
These are the ways this change goes wrong, and each has a done-when check in wave 10.
- **An empty password must be rejected before `bind()` is called.** 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 any username submitted. This is the single
highest-severity failure mode in the item and it gets its own test.
- **`validate=ssl.CERT_REQUIRED` with an explicit CA file.** Not `CERT_NONE`, and not the
system trust store. `CERT_NONE` still encrypts, so it fails silently — what it loses is
the ability to distinguish the real DC from an attacker who terminates the TLS session,
harvests the domain password and relays the bind onward. Since domain credentials now
cross that channel, a compromise escalates from "this app" to Windows, mail and file
shares. The system store is refused separately because it currently trusts five other
self-signed CAs (`prime-DR-CAPRIME-CA`, `prime-DR-CA_PRIME-CA`, `prime-DR-DC20-CA`,
`PRIME CONTROLS ISSUING CA 2`, and a stray `L55401TDKLY3.prime.local` machine cert in
Trusted Root).
- **The app's lockout must trip below the domain's.** `LOGIN_MAX_ATTEMPTS` currently writes
to the local `users` row. Once failures are binds, they count against the **AD** lockout
policy, so an unauthenticated caller hammering `/api/auth/login` can lock real domain
accounts out of Windows. The local throttle must stop calling the DC before the domain
threshold is reached.
- **Never leak which usernames exist.** `login()` today equalises response timing on purpose
so a caller cannot enumerate accounts. Directory error 49 sub-codes (`52e` bad password,
`532` password expired, `533` disabled, `775` locked) are useful in the log and must not
reach the response body.
### Answered August 21, 2026 — both were raised as open and both were decided
**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
- mTLS / client-certificate authentication (see above).
- Kerberos / SPNEGO single sign-on. It is the better long-term answer for domain-joined
desktops and needs a keytab, an SPN and browser trust configuration; it is not this item.
- Group-to-role mapping (e.g. an AD group that confers `project_admin`). Criterion 4 keeps
authorization local on purpose. Worth its own item later; logged in `backlog.md`.
- Replacing the Let's Encrypt certificate or changing anything on the OpenResty host.
---
## D14 — The CLI authenticates against the domain, and stops creating accounts
- **Amends:** `D13` criterion 4, which said role granting keeps working *from the Admin
console*. It said nothing about `manage_users.py`, which had no authentication of any
kind. Requiring one is a new requirement, so it gets its own id rather than widening
criterion 4.
- **Surface:** `server/manage_users.py`
- **Task:** `T10.9`
### The decision
1. **`create-admin` and `create` are removed.** `D13` provisions accounts on first
successful sign-in, so creating them by hand is redundant. Removing them also closes a
class of problem: every row now originates from a bind, so a username cannot be typed
in wrong and end up orphaned from the directory identity it was meant to match. That
risk now applies only to rows the old CLI already created.
2. **`promote` and `demote` replace them.** The directory supplies identity; this app
supplies authorization, and this is where authorization is assigned from a shell.
3. **Every state-changing command requires a domain bind.** Prompted, via `getpass`.
There is deliberately no `--password` flag: that would put a live domain password into
shell history and into `ps` output for every other user on the box.
4. **`list` needs no credential**, so an outage stays diagnosable.
### Bootstrapping the first admin, which changed shape
Two steps, in order: **sign in once** (which provisions the account at `project_user`),
then **`promote <sAMAccountName>`**. Before D14 the first admin was created with a
password; there is no password now, and no account to create.
### What this is worth, stated plainly
Anyone with a shell on the api container can still write to the `users` table directly
with `psql` or `sqlite3`. So the bind is **defence in depth and, mostly,
ACCOUNTABILITY** — not a security boundary. Before D14 every role change made 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
(no privileged group exists on this estate, and machine access is already restricted to a
few people), which was decided knowing the above.
### Two deliberate divergences from the API
- **The bind does NOT apply the login group gate.** If a mistyped required group locks
everyone out of the console, this tool has to still work — otherwise the only route to
fixing the lockout is the thing the lockout prevents.
- **Changing your OWN role is permitted here.** `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, so it is allowed and recorded with `{"self": true}` in the audit detail.
The last-admin guard is kept, matching `set_user_role`: an app with no admin cannot be
administered, and there is no password login left to recover through.

399
docs/waves/wave-10.md Normal file
View File

@@ -0,0 +1,399 @@
# Wave 10 — Domain authentication over LDAPS
**Items:** `D13`, `D14`
**Depends on:** wave 9 merged (it is — `a8e28bf`)
One item, eight tasks. The item is stated in `docs/waves/decisions-2026-08-21.md`; read it
before starting, particularly the **Non-negotiables** section, which is where this change
goes wrong if it goes wrong.
**This wave is field-visible in one direction only.** Nobody gains a screen. People sign in
with their Windows password instead of an app password, the "Forgot password?" link
disappears, and admins stop issuing passwords. Everything else is invisible — which means
the done-when checks are the only evidence the wave worked.
> **BEFORE PRODUCTION: `LDAP_REQUIRED_GROUP` must be set.** It is empty by default,
> and empty means *no group gate* — every account in `prime.local` may sign in. The
> successful live sign-in on Aug 24 was made without it, so it proved the bind, the
> certificate chain and JIT provisioning, but **not** the group check: that path never
> executed. The group gate is covered against the fake directory in `ldap_auth_check`
> and has never run against the real directory. The startup line says which state you
> are in — `required group: (none configured — every domain account may sign in)`.
**Build order is task order** for once. `T10.1` is a standalone module with no callers and
should merge first; nothing else can be tested until it exists.
---
### T10.1 — The LDAPS client
- **Items:** `D13` (1)
- **Depends on:** nothing
- **Blocks:** T10.2, T10.4, T10.5, T10.7
- **Surface:** `server/`
- **Files:** new `server/ldap_auth.py`, `server/requirements.txt`, `docker-compose.yml`,
`Dockerfile`, `server/.env.example`
**Problem:** There is no directory client in the repo. `ldap3` is not a dependency, the API
container has no CA bundle, and the `api` service sits on the `internal` network, which has
no default gateway and therefore no route to `192.168.3.x` at all.
**Do:** Add `ldap3` (pinned, per the convention at the top of `requirements.txt`). Write
`server/ldap_auth.py` exposing two functions and nothing else:
- `verify(username, password) -> LdapResult | None` — simple bind as
`f"{username}@{domain}"` against `ldaps://{host}:636`.
- `member_of(conn, group) -> bool` — nested-group aware, via
`LDAP_MATCHING_RULE_IN_CHAIN` (`1.2.840.113556.1.4.1941`). `memberOf` alone is direct
membership only and will wrongly refuse anyone in a nested group.
Configuration by environment: `LDAP_HOST` (default `prime.local`), `LDAP_DOMAIN` (default
`prime.local`), `LDAP_CA_FILE`, `LDAP_REQUIRED_GROUP`, `LDAP_TIMEOUT_SECONDS`. Attach the
`outbound` network to `api` in `docker-compose.yml` — the same reason `assets_db.py` needed
it, and the comment there already explains the gateway-less `internal` network. Mount or
`COPY` the PEM bundle and point `LDAP_CA_FILE` at it.
Connect to **`prime.local`**, never a DC hostname and never an IP. See the decision doc for
why: SAN coverage plus round-robin across six DCs in one move. Wrap the bind in a retry
across resolved addresses, because round-robin will hand out a rebooting DC's address.
**Done when:**
- [x] `ldap3` is pinned to an exact version in `requirements.txt`
- [x] an empty or whitespace-only password returns failure **without calling `bind()`** — proven with `Connection` nulled, so any call to `bind()` would raise
- [x] an empty username returns failure without calling `bind()`
- [x] `Tls` is constructed with `validate=ssl.CERT_REQUIRED` and an explicit `ca_certs_file`
- [x] no code path sets `CERT_NONE`, and none falls back to the system trust store — asserted by an AST walk in `ldap_auth_check`, not a grep: the docstring names it to explain the ban
- [~] `member_of` returns true for an account in a **nested** child of the required group — **partially closed Aug 24.** The transitive matching rule was exercised against the live directory for a DIRECT member (`CN=Prime Employees`, 2,003 members) and returned a match, so the rule and the filter are correct on this estate. A genuinely NESTED case (member of a group that is a member of the required group) still has no test, because no such account was to hand. `member_of` now also warns loudly if the transitive query returns nothing where a direct check succeeds, which is how a broken nested lookup would announce itself rather than refusing real members silently.
- [x] a bind against `192.168.3.37` (raw IP) fails hostname validation rather than silently passing — `selftest()` to the raw IP returns `untrusted`; to `prime.local`, `0 (ok)`
- [ ] `docker compose exec api openssl s_client -connect prime.local:636 -CAfile $LDAP_CA_FILE` reports `Verify return code: 0 (ok)`
- [x] the module imports cleanly with no LDAP env set (unconfigured is a first-class state, as with `MICRON_DB_URL`)
---
### T10.2 — The login path binds instead of hashing
- **Items:** `D13` (1)
- **Depends on:** T10.1
- **Blocks:** T10.3, T10.4
- **Surface:** `server/`
- **Files:** `server/app.py` (`login`), `server/auth.py`
**Problem:** `login()` at `server/app.py:681` calls `auth.verify_password` against
`user.password_hash`. The lockout counter it maintains is about to start counting *domain*
bind failures, which changes what that counter is for.
**Do:** Replace the credential check with `ldap_auth.verify`. Keep the surrounding shape —
the deliberate timing equalisation, the generic `401`, the `403` for a disabled local
account, `last_login_at`, the session cookie. Rework the throttle so the local counter trips
**before** the domain policy is reached and short-circuits without calling the DC: the
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.
**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:**
- [x] the API logs one line at startup saying whether LDAP is configured and which group gates sign-in — configuration only, so an unreachable DC cannot hang startup. This is what `DEPLOYMENT.md`, `DEPLOY-login-portal.md` and `server/README.md` all send people to first; it was specified in this task on Aug 21 and not actually written until Aug 24.
- [x] a correct domain password signs in and sets the session cookie — confirmed against the live domain Aug 24, and in `ldap_auth_check`
- [x] a wrong password is refused with the generic message
- [x] a blank password is refused (guards `T10.1` from the caller's side too)
- [x] a user not in the required group is refused even though the bind succeeded
- [x] `is_active = false` locally still refuses, independent of the directory — a disabled account is refused 403 even though the bind succeeds
- [x] the local throttle trips below the domain lockout threshold and stops calling the DC — proved by presenting a CORRECT password once the budget is spent: a 429 for a credential that would otherwise work is only possible if the throttle runs before the directory is consulted
- [x] no response body distinguishes "no such user" from "wrong password"
- [x] error-49 sub-codes appear in the log and nowhere in any response — asserted both ways: `_err49` parses a real AD message, and no sub-code appears in any response body
---
### T10.3 — Drop `password_hash`
- **Items:** `D13` (1)
- **Depends on:** T10.2
- **Blocks:** T10.6, T10.8
- **Surface:** `server/`
- **Files:** `server/models.py`, new migration, `server/app.py`, `server/auth.py`,
`server/manage_users.py`
**Problem:** With binds doing the work, every password code path is dead weight and a
liability. It is also the criterion that makes this item irreversible, so it lands on its own
commit.
**Do:** Remove `password_hash` from `models.User` and drop the column in a new Alembic
revision with `down_revision = 'a1b8c6d4e2f9'` (the current head — confirm with
`alembic heads` rather than trusting this line). Remove from `auth.py`: `hash_password`,
`verify_password`, `password_problem`, `MIN_PASSWORD_LEN`, `_COMMON_PASSWORDS`,
`create_reset_token`, `decode_reset_token`, `RESET_MINUTES`. Remove from `app.py`:
`/api/auth/forgot-password`, `/api/auth/reset-password`, `/api/auth/reset-available`,
`/api/auth/password`, `/api/auth/users/{user_id}/password`, and the `_reset_last` throttle
with `reset_body`. Remove `reset-password` from `manage_users.py` and the password prompt
from `create` / `create-admin`.
`token_version` **stays.** It is still the session-revocation mechanism — role changes and
deactivation should bump it even though password changes no longer exist.
**Do not** remove the account-management endpoints themselves. `/api/auth/users/{id}/role`
is criterion 4 and must keep working.
**Done when:**
- [x] `grep -rn "password_hash\|hash_password\|verify_password\|password_problem" server/` returns nothing outside the migration — only the migration and one docstring naming the dropped column
- [x] `alembic upgrade head` then `downgrade -1` round-trips on SQLite and on Postgres (16.15, the compose image — Aug 24; needed a pre-existing T8.6 migration bug fixed first, see `495d87d`)
- [x] the migration's `downgrade()` recreates the column nullable, not `NOT NULL` — there are no hashes to put back — verified on SQLite and Postgres
- [x] `token_version` still invalidates an already-issued session — but **not** via a role change, which was the wrong premise. Nothing in `app.py` bumps it any more: `get_current_user` re-reads the account every request, so `role` and `is_active` changes take effect immediately without it. Its one remaining trigger is `manage_users` on disable. Tested by bumping it directly: the old cookie 401s and every other session is untouched. See `BL-030`.
- [x] `manage_users.py list`, `disable`, `enable` still work; `reset-password` is gone
- [x] no CLI command prompts for a password — superseded by `T10.9`, which removed `create-admin` and `create` outright; the first admin is now bootstrapped by signing in and then `promote`
---
### T10.4 — Just-in-time provisioning, without trampling existing accounts
- **Items:** `D13` (2, 4)
- **Depends on:** T10.2
- **Blocks:** T10.7
- **Surface:** `server/`
- **Files:** `server/app.py` (`login`), `server/auth.py`
**Problem:** Criteria 2 and 4 pull in opposite directions. Provisioning on first login must
create accounts that do not exist, and must not touch the role of accounts that do — an
existing `admin` signing in for the first time after this wave must still be an admin
afterwards.
**Do:** On a successful bind that passes the group check, look the account up with
`auth.find_user` (already case-insensitive across username **and** email). If it exists,
update only `last_login_at` and — if empty locally — `full_name` and `email` from the
directory. **Never write `role`.** If it does not exist, create it at
`ROLE_PROJECT_USER` with `full_name`/`email` from the directory.
**A JIT account gets NO project access, and that is deliberate.** An earlier draft of this
task said to honour the `auto_add_projects` machinery so a new account "lands in the right
projects" — that was wrong about how the flag works. `auto_add_projects` is evaluated when a
**project** is created (`app.py:245`), marking accounts that should join every *new* job; it
cannot retroactively add a new account to existing ones. There is no correct default, so
least privilege applies: the account exists, can sign in, and sees nothing until someone
grants access. That is a real UX cliff — a successful sign-in into an empty app — so it has
to be visible to admins rather than silent, which is what the `AuditLog` row is for.
Note the flush-order warning in the `models.py` docstring: `create_user` in `app.py` handles
account-then-membership correctly in one flush — follow it if you add rows.
Write an `AuditLog` row for each JIT creation. An account appearing without an administrator
creating it is exactly the kind of event that record exists for.
**Done when:**
- [x] an unknown username with a valid bind and group membership gets a `users` row at `project_user`
- [x] `full_name` and `email` are populated from the directory on creation
- [x] an existing `admin` signing in is still `admin` afterwards — asserted, not assumed — asserted in `ldap_auth_check` against a real server
- [x] an existing account with a locally-set `full_name` does not have it overwritten
- [x] a JIT account has NO `ProjectMember` rows and sees no projects
- [x] the new account appears in the Admin console user list so access can be granted — asserted through `GET /api/auth/users`, the request the console makes
- [x] each JIT creation writes an `AuditLog` row
- [x] a failed bind creates **no** row
- [x] a bind that succeeds but fails the group check creates **no** row
---
### T10.5 — CLOSED, NOT BUILT (Aug 24 2026): the required group stays an env var
- **Items:** `D13` (3)
- **Status:** **won't build.** `LDAP_REQUIRED_GROUP` in the environment is the answer.
**Do not build this later by reading the original task and assuming it was skipped.**
It was proposed, examined and rejected on purpose, and the reasoning is below.
**What it was going to be:** the required group moved out of the environment into an
Admin console setting, with a validate-on-save guard that resolved the group in the
directory and confirmed the saving admin was a member.
**Why it is not being built:**
1. **The console requirement was invented here, not asked for.** D13 criterion 3 says
*"An AD group is configured"* — not "configurable from the console". The env var
satisfies the criterion as written.
2. **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.
3. **The lockout scenario it defended against is already handled.** 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**, not 401, and the log says *"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. The two are already distinguishable
in both the log and the response.
4. **A redeploy is deliberate and reviewable; a text box is not.** The group is set
once and effectively never changes — it is not SMTP configuration.
5. **The console version creates a circular failure.** Fixing a lockout would require
the console the lockout prevents you from reaching. Editing the env var does not.
**What is genuinely lost, and accepted:**
- **Discoverability.** An app admin cannot see which group is required without
Portainer or shell access. A read-only line in the Admin console diagnostics would
give the useful half without the dangerous half; it was offered and declined on
Aug 24 as not needed.
- **Deploy-time validation.** Nothing confirms the group resolves until the first
sign-in attempt. This is not fixable: resolving a group needs an authenticated
search, anonymous bind is disabled on this estate, and there is no service account
by design. The first-attempt 503 is the earliest possible detection.
### T10.6 — Strip the password UI
- **Items:** `D13` (1)
- **Depends on:** T10.3
- **Blocks:** nothing
- **Surface:** `html/`
- **Files:** `html/login.html`, `html/login.js`, `html/admin.js`, `html/users.js`,
`html/auth-guard.js`
**Problem:** `login.html` has three views — sign-in, forgot-password, set-new-password — and
two of them now point at endpoints that no longer exist. `auth-guard.js` has a
change-password dialog, and the user-admin UI has a "reset password" action per row.
**Decided Aug 21: "Forgot password?" is KEPT and repointed at
`https://primecontrols.okta.com/`.** An earlier draft of this task removed the link, and a
plain sentence saying "contact IT" was proposed instead. Okta is the better answer — it is a
real self-service path, and with no app password and no break-glass it is the only recovery
route that exists. Note this is the first sign of an Okta tenancy on this estate; see the
new backlog entry.
Mechanics that matter: it is a plain `<a href>`, not a form post, so the `form-action 'self'`
in the CSP does not apply and no `navigate-to` directive is set — off-origin link navigation
is allowed as-is. `target="_blank"` needs `rel="noopener noreferrer"`, and there must be NO
click handler on `#forgot-link`: the old one called `preventDefault()` to swap views, and
leaving it would silently swallow the navigation.
**Do:** Remove the `#view-forgot` and `#view-reset` sections and the reset-token handling in
`login.js`, and repoint `#forgot-link` as above. Remove the change-password dialog from `auth-guard.js`
(`backlog.md:180` refers to it) and the per-row password reset from the users UI. Keep the
sign-in form, and relabel the password field's hint to say it is the Windows/domain password
— people need to know which password to type.
Keep the role-granting controls exactly as they are. That is criterion 4.
**Done when:**
- [x] `grep -rn "forgot\|reset-password\|new-password" html/` returns nothing but prose
- [x] "Forgot password?" opens `https://primecontrols.okta.com/` in a new tab — by inspection of the markup: `target="_blank"` with `rel="noopener noreferrer"`
- [x] `#forgot-link` has NO click handler (a `preventDefault()` would swallow the navigation)
- [x] a 503 from the login endpoint says sign-in is unavailable, not that the password is wrong
- [x] the sign-in form still submits, and a failure still announces through `role="alert"` (`login.html` already does this correctly — do not regress it) — `url_state_check` drives the real form end to end; the `role="alert"` region is untouched
- [x] the password field says which password to enter — see the screenshots
- [x] no dead `<a href="#">` or handler remains for a removed view
- [x] granting admin to an existing user still works from the console — asserted through `POST /api/auth/users/{id}/role`, the request `users.js` sends, and the role really changes
- [x] exercised at 390px and at 1440px, screenshots in the PR — `docs/reference/baseline/before-wave10/` and `after-wave10/`, captured Aug 24 with `tests/baseline_shots.py`. "Before" comes from a detached worktree at `main` (`a8e28bf`) so each half was shot against its own server.
- [x] no raw hex added to any stylesheet (the token rule) — no CSS was added at all — the hint reuses the `.hint` class the page already had
---
### T10.7 — Make the suite testable without a domain controller
- **Items:** `D13` (1, 2, 3)
- **Depends on:** T10.1, T10.4
- **Blocks:** T10.8
- **Surface:** `tests/` + `server/`
- **Files:** `server/ldap_auth.py`, `tests/browser_check.py`, `tests/launcher_check.py`,
`tests/console_dialogs_check.py`, `tests/url_state_check.py`, `server/smoketest.py`,
`server/seed_demo.py`, new `tests/ldap_auth_check.py`
**Problem:** This is the task most likely to be underestimated. Four existing checks build
users with `password_hash=auth.hash_password(PW)` and sign in over HTTP;
`console_dialogs_check.py` drives the password-reset prompt specifically. `smoketest.py` and
`seed_demo.py` both sign in. None of them can reach a DC, and CI has no domain.
**Do:** Make the bind injectable — a module-level seam in `ldap_auth.py` that a test can
substitute (a fake directory: usernames, passwords, groups, full names), selected by an env
var that is refused when a real `DATABASE_URL` is configured, mirroring how
`auth._load_secret` refuses an ephemeral key in production. Port the four checks onto it.
Delete the password-reset half of `console_dialogs_check.py` — the flow it covers no longer
exists — and say so in the PR rather than leaving a skipped test.
Add `tests/ldap_auth_check.py` covering the non-negotiables from the decision doc: empty
password, empty username, `CERT_REQUIRED` asserted by inspecting the constructed `Tls`,
nested group membership, group-check refusal creating no user, and existing-admin role
preservation.
**Done when:**
- [x] the stub backend cannot be selected when a non-SQLite `DATABASE_URL` is set — asserted by a test
- [x] `tests/ldap_auth_check.py` covers the cases above — 20/20
- [x] the empty-password case is proved by nulling `Connection`, so any call to `bind()` would raise — it asserts the guard returns *before* the transport, not merely that the result is a failure
- [x] `smoketest.py` and `seed_demo.py` document which credentials they now need (T10.8)
- [x] the removed password-reset checks are called out, not silently dropped — `console_dialogs_check.py`'s docstring records the coverage loss and where the prompt kit is still covered
- [x] the full `tests/` suite passes with no DC reachable — **1284/1288 checks across 40 files**, Aug 24. Two files fail, both proven pre-existing and unrelated (`BL-028` `assets_check`, `BL-029` `generalinfo_check`): `git diff main...HEAD` shows this branch touches neither file. `token_check.py` is not a member of the suite — it is a capture/diff tool that requires `--out` or `--compare`, and a sweep script that runs it bare gets a usage message and exit 2.
**Scope note.** This task was estimated as far larger than it turned out to be. The
premise was that four checks sign in and would all need the seam; in fact `seed()` mints
a token with `auth.create_token()` and sets the cookie directly, so **no** browser check
signs in except `url_state_check`'s deep-link case. `browser_check` and `launcher_check`
needed one kwarg deleted each — and since 39 files import `seed`/`start_server` from
`browser_check`, that single line unblocked nearly the whole suite.
---
### T10.8 — Documentation and the deploy runbook
- **Items:** `D13`
- **Depends on:** T10.3, T10.5, T10.7
- **Blocks:** nothing
- **Surface:** docs
- **Files:** `DEPLOYMENT.md`, `server/README.md`, `DEPLOY-login-portal.md`, `CLAUDE.md`,
`IMPLEMENTATION.md`
**Problem:** `DEPLOY-login-portal.md` documents creating the first admin with a password and
is the page an admin will reach for. `DEPLOYMENT.md` describes `AUTH_SECRET_KEY` and SMTP but
knows nothing about a directory. `CLAUDE.md`'s verification section tells anyone touching the
frontend to run the smoke test, which changes here.
**Do:** Document the LDAP variables, how to produce the CA bundle from the two thumbprints
in the decision doc, and the `prime.local`-not-an-IP rule with the reason. Add the
`openssl s_client -CAfile` check as the first-line diagnostic. Rewrite the
`DEPLOY-login-portal.md` bootstrap step: the first admin is now an existing directory account
promoted with `manage_users.py`, not an account created with a password. State plainly what
happens when the DC is unreachable, whatever `T10.2` decides.
**Done when:**
- [x] every new env var is documented in `server/.env.example` and `DEPLOYMENT.md`
- [x] the CA bundle procedure is reproducible by an admin who has not read this thread — thumbprints and a `Get-ChildItem` one-liner in `DEPLOYMENT.md`
- [x] `DEPLOY-login-portal.md` no longer instructs anyone to set a password — rewritten, with a note saying what it replaced so an admin holding the old copy is not misled
- [x] the DC-unreachable behaviour is stated explicitly, with the diagnostic commands
- [x] `IMPLEMENTATION.md` section 4 lists wave 10
- [x] no doc still claims passwords are stored as bcrypt hashes (swept; remaining matches all say the opposite)
- [x] `CLAUDE.md` carries the four load-bearing auth rules, next to the token rule
---
### T10.9 — D14: the CLI authenticates, and stops creating accounts
- **Items:** `D14`
- **Depends on:** T10.3
- **Blocks:** T10.8
- **Surface:** `server/`
- **Files:** `server/manage_users.py`
**Problem:** `manage_users.py` writes to the `users` table with no authentication at all.
It also still offers `create-admin` / `create`, which are redundant now that accounts
provision themselves — and worse than redundant, because a hand-typed username can end up
matching no directory identity.
**Do:** As stated in `D14`. Remove the two create commands, add `promote` / `demote`, gate
every state-changing command on a prompted domain bind, and write an `AuditLog` row naming
the operator. Write the audit row by hand rather than importing `log_event` from `app.py`
that would pull FastAPI and the whole application into a CLI startup for one INSERT.
**Done when:**
- [x] `create-admin`, `create` and `reset-password` are rejected as invalid choices
- [x] `list` works with no credential and with no LDAP configured
- [x] a state-changing command with LDAP misconfigured refuses instead of proceeding
- [x] there is no `--password` flag on any command
- [x] `promote` raises a role; `demote` returns an account to `project_user`
- [x] promoting YOURSELF is allowed and recorded with `self: true`
- [x] the last active admin cannot be demoted
- [x] an unknown account gives an error that says accounts are made on first sign-in
- [x] every change writes an `AuditLog` row naming the operator
- [x] verified against a real domain bind — `promote` and `demote` confirmed working Aug 24 2026

View File

@@ -60,63 +60,6 @@
.then(function () { window.location.replace('login.html'); });
};
// Change-password dialog (uses POST /api/auth/password, which requires the
// current password). Available from the top-right pill on any page.
window.wpChangePassword = function () {
if (document.getElementById('wp-pw-modal')) return;
var ov = document.createElement('div');
ov.id = 'wp-pw-modal';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;';
var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin-bottom:4px;';
ov.innerHTML =
'<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Change password</div>' +
'<div style="padding:16px 18px;">' +
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
'<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
'</div>' +
'<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
'</div>' +
'</div>';
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
function msg(text, ok) {
var el = document.getElementById('wp-pw-msg');
el.style.display = 'block'; el.textContent = text;
el.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; el.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
}
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov);
document.getElementById('wp-pw-cancel').onclick = close;
document.getElementById('wp-pw-cur').focus();
document.getElementById('wp-pw-save').onclick = function () {
var cur = document.getElementById('wp-pw-cur').value;
var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
fetch('/api/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: cur, new_password: n1 })
})
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
.then(function (res) {
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
})
.catch(function () { msg('Could not reach the server.', false); });
};
};
// ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in
// user can't use, so nobody clicks a button just to get a 403.

View File

@@ -112,47 +112,16 @@
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<div class="hint">Use your Windows password — the same one you use to sign in to your computer.</div>
<button id="submit" type="submit">Sign in</button>
</form>
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
</section>
<!-- FORGOT PASSWORD (email reset) -->
<section id="view-forgot" style="display:none">
<h1>Reset password</h1>
<p class="sub">We'll email you a link to set a new one.</p>
<div id="forgot-unavailable" class="note" style="display:none">
Password reset by email isn't switched on yet. Contact your project admin and
they'll set a new password for you. Once you're signed in you can change it
yourself from the menu in the top-right corner.
</div>
<form id="forgot-form" autocomplete="on">
<div class="field">
<label for="forgot-username">Username or email</label>
<input id="forgot-username" type="text" autocomplete="username" required>
</div>
<button id="forgot-submit" type="submit">Email me a reset link</button>
</form>
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
</section>
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
<section id="view-reset" style="display:none">
<h1>Set a new password</h1>
<p class="sub">Choose a password you don't use anywhere else.</p>
<form id="reset-form" autocomplete="on">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
</div>
<div class="hint">At least 12 characters.</div>
<div class="field">
<label for="new-password2">Confirm new password</label>
<input id="new-password2" type="password" autocomplete="new-password" required>
</div>
<button id="reset-submit" type="submit">Set password &amp; sign in</button>
</form>
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
<!-- D13: there is no app password to reset. Self-service goes to Okta.
A plain external link, not a form post — CSP sets form-action 'self'
and does not set navigate-to, so link navigation off-origin is allowed.
rel="noopener noreferrer" because target="_blank" without it hands the
opened page a window.opener handle back to this one. -->
<p class="center"><a href="https://primecontrols.okta.com/" id="forgot-link" class="link"
target="_blank" rel="noopener noreferrer">Forgot password?</a></p>
</section>
<p class="foot">Authorized use only · BTG / Pilot</p>

View File

@@ -1,26 +1,20 @@
/* Login page logic for the Work Package Suite.
Three views on one page:
• sign in posts to /api/auth/login. On success the server sets an
HttpOnly session cookie (not readable here — that's the
point) and we redirect to ?next= or the home page.
• forgot password posts to /api/auth/forgot-password, which emails a
single-use link. Only offered when the server reports
email is actually configured (/api/auth/reset-available);
otherwise we say to ask an admin.
• set a new password shown when the page is opened as login.html?reset=<token>
from that email. Posts to /api/auth/reset-password.
One view. Sign in posts to /api/auth/login, the server authenticates by binding
to the domain over LDAPS (D13), and on success sets an HttpOnly session cookie —
not readable from here, which is the point — after which we redirect to ?next=
or the home page. The password entered is the person's WINDOWS password.
The reset token stays in the URL only until it's used; on success we strip it
from the address bar so it isn't left in history or copied out of the bar. */
There is no forgot-password flow and no reset view: the suite holds no password
to reset. "Forgot password?" is a plain external link to Okta in login.html, so
there is deliberately no click handler for it here — one that called
preventDefault() would swallow the navigation. */
(function () {
'use strict';
var errorBox = document.getElementById('error');
var okBox = document.getElementById('ok');
function show(el) { if (el) el.style.display = ''; }
function hide(el) { if (el) el.style.display = 'none'; }
function byId(id) { return document.getElementById(id); }
function showError(msg) {
@@ -28,11 +22,6 @@
errorBox.textContent = msg;
errorBox.classList.add('show');
}
function showOk(msg) {
errorBox.classList.remove('show');
okBox.textContent = msg;
okBox.classList.add('show');
}
function clearBanners() {
errorBox.classList.remove('show');
okBox.classList.remove('show');
@@ -49,10 +38,6 @@
return 'index.html';
}
function resetToken() {
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; }
}
function postJson(url, payload) {
return fetch(url, {
method: 'POST',
@@ -70,18 +55,11 @@
return (typeof d === 'string' && d) ? d : fallback;
}
function view(which) {
clearBanners();
['login', 'forgot', 'reset'].forEach(function (v) {
(which === v ? show : hide)(byId('view-' + v));
});
}
// ── sign in ────────────────────────────────────────────────────────────────
var form = byId('login-form');
var submitBtn = byId('submit');
// Guarded because a cached older login.html may not have the reset views; an
// unguarded addEventListener on null would break sign-in itself.
// Guarded: an unguarded addEventListener on null would break sign-in itself if a
// cached older login.html were served.
if (!form || !submitBtn) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
@@ -98,6 +76,10 @@
if (res.status === 401) showError('Invalid username or password.');
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
// 503 means the directory is unreachable or misconfigured — OUR fault, not a
// wrong password. Saying so stops people hunting for a password they no
// longer have while a deploy is broken.
else if (res.status === 503) showError(detail(res, 'Sign-in is temporarily unavailable. Contact IT.'));
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
@@ -109,107 +91,4 @@
});
});
// ── forgot password ────────────────────────────────────────────────────────
var resetAvailable = null; // null = not checked yet
function checkResetAvailable() {
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
return fetch('/api/auth/reset-available')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
.catch(function () { resetAvailable = false; return false; });
}
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('forgot');
// Prefill from the sign-in box so nobody types their username twice.
var u = byId('username').value.trim();
if (u) byId('forgot-username').value = u;
checkResetAvailable().then(function (enabled) {
// With email off there's nothing to submit — say so and hide the form.
(enabled ? hide : show)(byId('forgot-unavailable'));
(enabled ? show : hide)(byId('forgot-form'));
if (enabled) byId('forgot-username').focus();
});
});
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var forgotForm = byId('forgot-form') || document.createElement('form');
var forgotBtn = byId('forgot-submit') || document.createElement('button');
forgotForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var who = byId('forgot-username').value.trim();
if (!who) { showError('Enter your username or email.'); return; }
forgotBtn.disabled = true;
forgotBtn.textContent = 'Sending…';
postJson('/api/auth/forgot-password', { username: who })
.then(function (res) {
if (res.status === 503) {
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
} else if (res.ok) {
// Deliberately the same message whether or not the account exists.
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
hide(forgotForm);
} else {
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
}
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
});
});
// ── set a new password (from the emailed link) ──────────────────────────────
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var resetForm = byId('reset-form') || document.createElement('form');
var resetBtn = byId('reset-submit') || document.createElement('button');
resetForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var token = resetToken();
var pw = byId('new-password').value;
var pw2 = byId('new-password2').value;
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
resetBtn.disabled = true;
resetBtn.textContent = 'Saving…';
postJson('/api/auth/reset-password', { token: token, new_password: pw })
.then(function (res) {
if (res.ok) {
// Take the token out of the URL before anything else — it's spent.
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
view('login');
showOk('Password updated. Sign in with your new password.');
byId('username').focus();
return;
}
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
});
});
// Arriving from the reset email opens straight into the new-password view.
if (resetToken()) view('reset');
})();

View File

@@ -26,9 +26,8 @@
room for "Assistant Project Manager" without pushing Actions off screen. */
#users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
/* The create form is a lot of fields; give the password one room to breathe and
/* The create form is a lot of fields; let them wrap and
let the project picker take a full row of its own. */
#nu-password{ flex:1 1 200px; }
#nu-projects{ margin-top:var(--s2); }
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
/* A manager with one project doesn't need a scrolling picker; a manager with
@@ -88,7 +87,6 @@
<input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role" title="Permissions — what this account may do"></select>
<select id="nu-project-role" title="Job function on the project"></select>
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
</div>
<div id="nu-projects">
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>

View File

@@ -183,11 +183,12 @@ function managerRow(u){
: projRoleReadonly(u, can, why);
const actions = [];
if(can && !me) actions.push('<button class="mini" onclick="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
(u.is_active?'Disable':'Enable')+'</button>');
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
if(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</button>');
// D13: no self-service action left on your own row — the domain owns the password
// and role changes are never self-applied.
if(me) actions.push('<span class="note" style="margin:0" title="Your own account">you</span>');
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
@@ -254,19 +255,6 @@ function projAccessCell(u){
// ── row actions ───────────────────────────────────────────────────────────────
// Each one reloads on failure so a control can never sit there showing a value the
// server refused.
async function resetPw(id, username){
// The min-12 rule was stated in the prompt label and enforced only by the
// server round-trip; the kit's validate() answers AT the input instead.
const pw = await wpPromptDialog({title:'Reset password',
message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
label:'New password (min 12 characters)',
validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
if(pw === null) return;
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
}
async function toggleActive(id, makeActive){
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
if(status === 200) loadUsers();
@@ -345,24 +333,22 @@ async function createUser(){
const msg = document.getElementById('users-create-msg');
const val = id => (document.getElementById(id)||{}).value || '';
const username = val('nu-username').trim();
const password = val('nu-password');
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
.map(c => c.value);
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
if(!username){ say('var(--red)','Username is required.'); return; }
if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; }
if(_scope.scope !== 'all' && !project_ids.length){
say('var(--red)','Pick at least one project — you administer users per project.'); return;
}
say('var(--muted)','Creating…');
const { status, json } = await api('POST','/api/auth/users',{
username, password, project_ids,
username, project_ids,
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
role: val('nu-role'), project_role: val('nu-project-role'),
});
if(status === 200){
say('var(--green)','✓ Created '+username+'.');
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
loadUsers();
} else {
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));

View File

@@ -53,7 +53,6 @@
{ section: 'Account' },
{ action: 'wpPreferences', icon: '◷', label: 'Language & time',
sub: 'Dates, numbers and time zone' },
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
];
function esc(v) {

View File

@@ -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

View File

@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/health` | liveness check (unauthenticated) |
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie |
| POST | `/api/auth/login` | sign in (`{username, password}`) — binds against the domain, sets the session cookie |
| POST | `/api/auth/logout` | clear the session cookie |
| GET | `/api/auth/me` | the logged-in user |
| POST | `/api/auth/password` | change your own password |
| GET | `/api/auth/users` | list accounts (**admin**) |
| POST | `/api/auth/users` | create an account (**admin**) |
| POST | `/api/auth/users` | pre-create an account (**admin**) — optional; accounts self-provision on first sign-in |
| POST | `/api/auth/users/{id}/role` | change an account's permissions role (**admin**) |
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
| POST | `/api/sops` | create/update a SOP (upsert by `id`) |
| GET | `/api/sops` | list SOP summaries |
@@ -40,9 +40,14 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
---
## Login portal (user accounts)
## Sign-in (domain authentication, D13)
The suite is gated by a username/password login. Sign-in issues a signed JWT
**The suite stores no passwords.** Signing in performs an LDAPS **simple bind** to
`ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
person typed — their Windows password. A successful bind is the authentication.
See `server/ldap_auth.py`; the schema has no `password_hash` column.
Sign-in issues a signed JWT
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
marked **Secure** automatically whenever the request arrives over HTTPS (via
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
@@ -53,8 +58,43 @@ with `401` unless a valid session cookie is present (see `auth_gate` in
`app.py`). The static pages additionally include `auth-guard.js`, which redirects
to `login.html` when there's no session — that's for UX, not protection.
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are
`admin` (may manage users) and `user`.
**The directory supplies identity; this app supplies authorization.** Roles live in
the local `users` table and are never read from AD — so an existing admin stays an
admin. Roles are `admin`, `project_super_user`, `project_admin`, `project_user`.
**Accounts are created on first successful sign-in.** Anyone who binds successfully
and is in the required group gets a `users` row at `project_user` with **no project
access** — they can sign in and will see nothing until an admin grants access. That
is least privilege, and it is deliberate; the creation is written to the audit log
so it is visible rather than silent.
**A required AD group gates sign-in.** `LDAP_REQUIRED_GROUP` (a group name or a full
DN; nested groups count). Empty means any domain account may sign in.
**There is no password reset and no break-glass.** The login page links to
`https://primecontrols.okta.com/` for password self-service. If the domain is
unreachable, or `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
**nobody can sign in, including admins** — the API logs one line at startup saying
whether LDAP is configured and reachable, so check `docker compose logs api` first.
**Connect to the domain name, never a DC hostname or an IP.** Every DC 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 never happen: domain passwords cross this link.
**The trust anchor is a CA certificate, not one issued to this app.** The API is the
TLS *client*, and clients present nothing. `server/certs/prime-ca-chain.pem` holds
`PRIME CONTROLS ROOT CA` + `PRIME CONTROLS ISSUING CA 1` — public certificates, no
private key, nothing to request from IT. Override the path with `LDAP_CA_FILE`.
Diagnose the connection without touching an account (no bind, so it cannot
contribute to a lockout):
```bash
docker compose exec api openssl s_client -connect prime.local:636 -CAfile /app/server/certs/prime-ca-chain.pem </dev/null 2>&1 | grep "Verify return"
# want: Verify return code: 0 (ok)
```
### Set the signing secret
@@ -65,30 +105,70 @@ without it the API uses a random per-process key, so logins reset on restart.
python -c "import secrets; print(secrets.token_urlsafe(48))"
```
### Create the first admin
### Bootstrap the first admin
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a
shell (run from the **project root**, like uvicorn):
Two steps, in this order. There is no `create-admin` any more — there is no password
to set and no account to create.
```bash
python -m server.manage_users create-admin alice --name "Alice Smith"
# prompts for a password (min 8 chars)
# 1. Sign in to the app once. That provisions your account at project_user.
# 2. Promote it:
docker compose exec api python -m server.manage_users promote alice
```
In Docker:
It prompts for **your** domain username and password, binds to confirm who you are,
and prints `alice: project_user -> admin`.
```bash
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith"
```
Other commands: `list`, `promote <user> [--role …]`, `demote <user>`,
`disable <user>`, `enable <user>`. After that, admins manage accounts from the Admin
console.
Other commands: `create <user> --role user`, `list`, `reset-password <user>`,
`disable <user>`, `enable <user>`. After that, admins can add users through the
API (or you can keep using the CLI).
**Every command that changes anything requires a domain bind** (D14), prompted —
there is deliberately no `--password` flag, which would put a live domain password
into shell history and `ps` output. `list` needs no credential so an outage stays
diagnosable. The bind here does **not** apply the required-group gate, so a mistyped
group cannot lock you out of the tool that fixes it.
Be clear on what the bind is worth: anyone with a shell here can still write to the
`users` table with `psql`. It is defence in depth and, mostly, **accountability**
every role change now writes an audit row naming a person, which shell changes
previously did not.
---
## Local dev
> ### A SQLite database created before D13 will reject new sign-ins
>
> `Base.metadata.create_all()` creates missing tables; it never alters existing ones.
> So a `wpsuite.db` built before D13 still has `users.password_hash` declared
> `NOT NULL` with no default, while the current model has no such column — and an
> INSERT that omits it is rejected:
>
> ```
> IntegrityError: NOT NULL constraint failed: users.password_hash
> ```
>
> Accounts already in the file keep working, so **you** can sign in and nothing looks
> wrong. It breaks the moment a *new* person signs in, because provisioning them is an
> INSERT — and it surfaces as an HTTP **500**, not a 401, so it reads as a server fault
> rather than anything to do with the schema.
>
> Such a database also has no `alembic_version` table, so `alembic upgrade head` would
> try to replay the baseline against tables that already exist. Stamp it first:
>
> ```bash
> python -m alembic -c server/alembic.ini stamp a1b8c6d4e2f9 # the revision before the drop
> python -m alembic -c server/alembic.ini upgrade head # runs only the drop
> ```
>
> That keeps whatever is in the file. Deleting the database also works and
> `create_all()` rebuilds a correct schema, but it throws away your test data.
>
> Production is unaffected: it runs Postgres and the container applies migrations at
> start, so the column is dropped properly there.
```bash
cd server
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate

View File

@@ -32,10 +32,11 @@ def upgrade() -> None:
sa.Column('code', sa.String(length=80), nullable=False, server_default=''),
sa.Column('description', sa.String(length=300), nullable=False, server_default=''),
sa.Column('unit', sa.String(length=20), nullable=False, server_default=''),
# sa.true(), not sa.text('1'): SQLite coerces integer 1 to boolean,
# Postgres refuses it (DatatypeMismatch) - found when this migration
# took down the wp.controls.dev api container on 2026-08-21. The
# location-taxonomy migration next door had it right all along.
# sa.true(), NOT sa.text('1'). sa.text() emits raw SQL, and Postgres refuses
# an integer default on a boolean column: "column active is of type boolean
# but default expression is of type integer". SQLite accepts 1 happily, so
# this passed every local test and failed only on the real engine. The
# sibling migration e2a4c7d91b30 does the identical column correctly.
sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column('sort', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),

View File

@@ -0,0 +1,56 @@
"""drop users.password_hash — D13 / T10.3
Authentication moved to an LDAPS simple bind against the domain (see
server/ldap_auth.py), so the suite no longer holds a credential of any kind.
THIS MIGRATION DESTROYS DATA AND CANNOT BE UNDONE IN ANY MEANINGFUL SENSE.
`downgrade()` recreates the column, but every hash in it is gone — and even a
restored hash would be useless, because nothing reads the column any more. The
downgrade exists so the revision is well-formed and so an operator can step back
past it, not because stepping back restores the old login. To actually revert to
local passwords you have to revert the application code and reset every password
by hand.
The column is recreated NULLABLE on downgrade, deliberately. The baseline schema
declared it NOT NULL, but there are no values to put back, so a NOT NULL column
with no server default would refuse to add itself on any table that has rows.
DO NOT WRAP THIS IN batch_alter_table. An earlier version of this migration did,
and it silently deleted every row of `project_members` on SQLite.
Why: alembic's batch mode emulates ALTER on SQLite by rebuilding the table —
create a new one, copy the rows, DROP the original, rename. `server/alembic/env.py`
imports the engine from `server/db.py`, which registers a `connect` listener setting
`PRAGMA foreign_keys=ON`, so that DROP TABLE cascades through
`project_members.user_id`, which is declared `ondelete="CASCADE"`. Every project
membership in the database goes with it, with no error and nothing in the log.
Turning the pragma off around the batch is not a fix either: `PRAGMA foreign_keys`
is a no-op inside a transaction, and alembic runs migrations in one.
The real answer is that the rebuild is unnecessary. SQLite gained native
ALTER TABLE ... DROP COLUMN in 3.35 (2021); this runtime has 3.42 and Postgres has
always had it. A plain drop_column touches one table and cascades nowhere.
Revision ID: b7e4f1a20c93
Revises: a1b8c6d4e2f9
Create Date: 2026-08-21
"""
import sqlalchemy as sa
from alembic import op
revision = 'b7e4f1a20c93'
down_revision = 'a1b8c6d4e2f9'
branch_labels = None
depends_on = None
def upgrade():
# Plain, un-batched, on both engines. See the docstring: batching this destroys
# project_members on SQLite.
op.drop_column('users', 'password_hash')
def downgrade():
op.add_column('users', sa.Column('password_hash', sa.String(length=200),
nullable=True))

View File

@@ -9,6 +9,8 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
Interactive docs: http://<host>/api/docs
"""
import base64
import logging
from contextlib import asynccontextmanager
import os
import re
import uuid
@@ -26,7 +28,11 @@ from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session
from .db import Base, engine, get_db
from . import models, auth, notify, assets_db
from . import models, auth, notify, ldap_auth, assets_db
# Same naming as the other modules' loggers (wpsuite.auth / .ldap / .notify), so a
# deployment can raise the level on one subsystem without raising it on all of them.
log = logging.getLogger("wpsuite.api")
# Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -39,7 +45,30 @@ if engine.dialect.name == "sqlite":
# Interactive docs are handy in dev but hand an attacker the full API map in prod,
# so enable them only on the SQLite dev fallback (production runs on Postgres).
_docs_enabled = engine.dialect.name == "sqlite"
@asynccontextmanager
async def _lifespan(_app):
"""Say, once, whether anyone can sign in at all.
D13 removed the local password path and left no break-glass, so a broken LDAP
configuration and a forgotten password look identical at the login box. This
line is what tells an operator which one they have, and DEPLOYMENT.md,
DEPLOY-login-portal.md and server/README.md all send people here first:
docker compose logs api | grep -i "LDAP auth"
Configuration only — it opens no connection and binds nothing, so startup stays
fast and cannot be made to hang by an unreachable domain controller. Use
`ldap_auth.selftest()` for a reachability check; it validates the certificate
without binding, so it cannot contribute to a lockout either.
"""
log.info("%s", ldap_auth.describe())
yield
app = FastAPI(
lifespan=_lifespan,
title="Work Package Suite API",
docs_url="/api/docs" if _docs_enabled else None,
redoc_url=None,
@@ -611,8 +640,10 @@ class LoginIn(BaseModel):
class NewUserIn(BaseModel):
# No password: D13 authenticates against the domain, so an administrator
# pre-creating an account only supplies identity and authorization. The person
# signs in with their Windows password, or is provisioned on first sign-in.
username: str
password: str
full_name: str = ""
email: str = ""
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
@@ -634,24 +665,6 @@ class PreferencesIn(BaseModel):
timezone: Optional[str] = None
class ForgotPasswordIn(BaseModel):
username: str = "" # username or email
class ResetPasswordIn(BaseModel):
token: str
new_password: str
class PasswordChangeIn(BaseModel):
current_password: str
new_password: str
class AdminPasswordIn(BaseModel):
new_password: str
class ActiveIn(BaseModel):
is_active: bool
@@ -674,42 +687,214 @@ class AutoAddIn(BaseModel):
role: str = ""
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
# D13 / T10.2 — THIS NUMBER IS NOT A UX PREFERENCE, IT IS A SAFETY LIMIT.
#
# Failures are now LDAP binds, so every one counts against the DOMAIN account
# lockout policy. This estate's AD threshold is 5. The throttle below is
# per-process and the API runs 2 gunicorn workers, so a local limit of N lets up
# to 2N binds reach a domain controller: 2 x 2 = 4, one under the threshold.
#
# It defaulted to 5 before this task, which would have allowed up to 10 binds and
# locked the account out of WINDOWS — twice over — before the local lockout ever
# engaged. If you raise this, or add a worker, redo the arithmetic first.
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "2"))
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
# Pre-account throttle, keyed by USERNAME (not by client IP — an attacker rotating
# IPs would sail past an IP-keyed limit, and it is the domain account we are
# protecting, not this endpoint's capacity).
#
# The DB counter on `users` is shared across workers and persists, but it can only
# work for an account that already has a local row. Under D13 accounts are created
# on first successful login, so a REAL domain account can be hammered before any
# row exists — this dict covers exactly that window. Per-worker and lost on
# restart, which is why LOGIN_MAX_ATTEMPTS is halved rather than trusted.
_bind_attempts: dict[str, list[float]] = {}
_BIND_WINDOW_SECONDS = LOGIN_LOCKOUT_MINUTES * 60
def _bind_throttled(sam: str) -> bool:
"""True if this username has already used its bind budget in the window.
Checked BEFORE any call to the directory."""
now = monotonic()
key = (sam or "").strip().lower()
hits = [t for t in _bind_attempts.get(key, []) if now - t < _BIND_WINDOW_SECONDS]
_bind_attempts[key] = hits
if len(_bind_attempts) > 5000: # bound the dict on a long-lived worker
for k in [k for k, v in _bind_attempts.items()
if not v or now - max(v) > _BIND_WINDOW_SECONDS]:
_bind_attempts.pop(k, None)
return len(hits) >= LOGIN_MAX_ATTEMPTS
def _record_bind_failure(sam: str) -> None:
_bind_attempts.setdefault((sam or "").strip().lower(), []).append(monotonic())
def _clear_bind_failures(sam: str) -> None:
_bind_attempts.pop((sam or "").strip().lower(), None)
def _match_directory_account(db: Session, result) -> Optional[models.User]:
"""Find the local row for a directory identity — D13 criterion 4.
Matched on `sAMAccountName` OR the directory's `mail`, because existing accounts
were created by hand with `manage_users.py` and some were typed as short logon
names while others were typed as email addresses. Matching on both is what keeps
an existing admin's role instead of handing them a second, default-role account.
`auth.find_user` already compares case-insensitively against username AND email,
so each call covers two columns; the second call is for the case where the local
username is the person's address and the directory only told us their sAMAccountName.
"""
user = auth.find_user(db, result.sam)
if user is None and result.mail:
user = auth.find_user(db, result.mail)
if user is not None:
log.info("matched directory identity %r to existing local account %r by mail",
result.sam, user.username)
return user
def _provision_from_directory(db: Session, result) -> models.User:
"""Create a local account for someone who just authenticated and has no row.
Lands at `project_user` with NO project memberships. That is least privilege and
it is deliberate, but it means the person signs in successfully into an empty
app until an admin grants access — so it is written to the audit log rather than
happening silently. `auto_add_projects` cannot help here: it is evaluated when a
PROJECT is created, to mark who joins every new job, and cannot retroactively add
a new account to jobs that already exist.
"""
u = models.User(
id=gen_id("user"),
username=result.sam,
email=result.mail or "",
full_name=result.full_name or "",
role=auth.ROLE_PROJECT_USER,
)
db.add(u)
db.flush() # see the flush-order note in models.py's docstring
log_event(db, u.username, "user_provisioned", "user", u.id, summary=u.username,
detail={"source": "directory", "role": u.role, "upn": result.upn,
"projects": 0, "note": "created on first successful sign-in"})
log.info("provisioned local account %r from the directory at role %r with no "
"project access — an admin must grant access before they see anything",
u.username, u.role)
return u
@app.post("/api/auth/login")
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
"""Verify credentials and, on success, set the HttpOnly session cookie.
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
user = auth.find_user(db, body.username)
"""Authenticate against the domain (D13 / T10.2) and set the session cookie.
The suite stores no passwords: this is an LDAPS simple bind as
`<sAMAccountName>@prime.local`, and a successful bind IS the authentication.
See server/ldap_auth.py for the transport and its two hard guards.
ORDER MATTERS HERE. Every throttle check runs BEFORE the directory is touched,
because a failed bind counts against the DOMAIN lockout policy — so this
endpoint must not be usable to lock a colleague out of Windows. Nothing below
reaches `ldap_auth.verify` until the local budget has been checked twice: once
against the persistent per-account counter, once against the pre-account
window that covers usernames with no local row yet.
Three outcomes, deliberately distinguished:
401 the credential was rejected, or the account is not in the required
group. One generic message for every case — the response must never
reveal whether an account exists (see `_ERR49` in ldap_auth: the useful
detail goes to the log).
403 the local account exists and is disabled. Independent of the directory.
503 OUR fault — LDAP unconfigured, unreachable, untrusted, or the required
group does not resolve. D13 left no password fallback, so this must not
masquerade as 401: "your password is wrong" sends people hunting for a
password they no longer have, while the real problem is a broken deploy.
"""
now = models.utcnow()
# Always run the hash comparison first — even for missing or locked accounts —
# so response timing doesn't leak which usernames exist. verify_password
# tolerates an empty hash.
valid = auth.verify_password(body.password, user.password_hash if user else "")
sam = ldap_auth.normalize_username(body.username)
if not sam or not (body.password or "").strip():
# No directory call for empty input. ldap_auth.verify guards this too; the
# duplication is intentional, since an empty password would otherwise be an
# anonymous bind and anonymous binds SUCCEED.
raise HTTPException(status_code=401, detail="Invalid username or password")
user = auth.find_user(db, sam)
# ── throttle 1: the persistent per-account lockout ────────────────────────
locked = user.locked_until if user else None
if locked is not None and locked.tzinfo is None:
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes
if locked is not None and locked > now:
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
if not user or not valid:
# ── throttle 2: the pre-account window ────────────────────────────────────
if _bind_throttled(sam):
log.warning("refusing to bind for %r — local attempt budget (%d) spent; "
"protecting the domain account from lockout", sam, LOGIN_MAX_ATTEMPTS)
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
# ── the directory ─────────────────────────────────────────────────────────
settings = notify.get_settings(db)
result = ldap_auth.verify(sam, body.password,
required_group=settings.get("ldap_required_group"))
if result.is_config_problem:
# Ours, not theirs. Never counted against the user, never a 401.
log.error("sign-in unavailable — %s: %s", result.reason, result.detail)
raise HTTPException(status_code=503,
detail="Sign-in is temporarily unavailable. Contact IT.")
if not result.ok:
# WARNING, not INFO: this is the line an operator needs when someone
# cannot sign in, and nothing configures the root logger — under plain
# uvicorn an INFO record from wpsuite.* goes nowhere, so the reason was
# invisible in exactly the situation it exists for.
log.warning("sign-in refused for %r (%s: %s)", sam, result.reason, result.detail)
_record_bind_failure(sam)
if user:
user.failed_attempts = (user.failed_attempts or 0) + 1
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
user.failed_attempts = 0
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
log_event(db, user.username, "login_locked", "user", user.id,
summary=user.username,
detail={"minutes": LOGIN_LOCKOUT_MINUTES, "reason": result.reason})
db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
# ── authenticated ─────────────────────────────────────────────────────────
_clear_bind_failures(sam)
# The pre-bind lookup used the typed name only. Now that the directory has told
# us the account's real sAMAccountName and mail, re-match on both (T10.4).
if user is None:
user = _match_directory_account(db, result)
provisioned = user is None
if provisioned:
user = _provision_from_directory(db, result)
else:
# NEVER touch `role` here. An existing admin stays an admin — that is D13
# criterion 4, and it is the whole reason this branch is separate from the
# one above. Fill in identity fields only where they are empty locally, so a
# name deliberately set in the console is not overwritten by the directory.
if not user.full_name and result.full_name:
user.full_name = result.full_name
if not user.email and result.mail:
user.email = result.mail
if not user.is_active:
# Checked after provisioning so a brand-new account (is_active defaults True)
# is not caught by it, and after the role branch so a disabled admin is still
# refused. Local state overrides the directory: disabling here is how you
# revoke access to THIS app without touching the domain account.
raise HTTPException(status_code=403, detail="Account is disabled")
user.failed_attempts = 0
user.locked_until = None
user.last_login_at = now
db.commit()
db.refresh(user)
token = auth.create_token(user)
auth.set_session_cookie(response, request, token)
return {"user": user.to_dict()}
@@ -721,115 +906,6 @@ def logout(response: Response):
return {"ok": True}
# ── Self-service password reset (needs email switched on) ──────────────────────
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
# restart — deliberately simple; the token expiry is the real control.
_reset_last: dict[str, float] = {}
def _reset_throttled(request: Request, username: str) -> bool:
now = monotonic()
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
prev = _reset_last.get(key)
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
return True
_reset_last[key] = now
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
cutoff = now - RESET_COOLDOWN_SECONDS
for k in [k for k, t in _reset_last.items() if t < cutoff]:
_reset_last.pop(k, None)
return False
def reset_body(user: "models.User", link: str, minutes: int) -> str:
# No account detail beyond the username, and no customer data — same rule as
# the assignment mail. The link is the only sensitive thing in here.
who = user.full_name or user.username
return (
f"Hi {who},\n\n"
f"A password reset was requested for your Work Package Suite account "
f"({user.username}).\n\n"
f"Set a new password:\n{link}\n\n"
f"The link expires in {minutes} minutes and can only be used once. "
f"If you didn't request this, you can ignore this email — your current "
f"password still works.\n"
)
@app.get("/api/auth/reset-available")
def reset_available(db: Session = Depends(get_db)):
"""Whether the login page should offer 'Forgot password'. Self-service reset
depends entirely on outbound email, so it's off unless email is enabled AND
SMTP is configured — otherwise the only route is an admin reset."""
s = notify.get_settings(db)
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
@app.post("/api/auth/forgot-password")
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
"""Email a reset link. Always returns the same 200 response whether or not the
account exists — this endpoint is unauthenticated, so it must not become a
username/email oracle. Failures are recorded in the audit log instead."""
s = notify.get_settings(db)
if not (s.get("email_enabled") and notify.smtp_ready(s)):
raise HTTPException(
status_code=503,
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
)
if _reset_throttled(request, body.username):
# Same shape as the success response — no oracle, no mail bomb.
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
user = auth.find_user(db, body.username)
if user and user.is_active and user.email:
base = (s.get("app_base_url") or "").rstrip("/")
token = auth.create_reset_token(user)
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
sent = notify.send_now(
db, user.email,
"Work Package Suite — reset your password",
reset_body(user, link, auth.RESET_MINUTES),
)
log_event(db, user.username, "password_reset_requested", "user", user.id,
summary=user.username, detail={"emailed": bool(sent)})
db.commit()
else:
# Log the miss for the admin's benefit; the caller can't tell the difference.
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
summary=(body.username or "")[:200],
detail={"reason": "no account, inactive, or no email on file"})
db.commit()
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
@app.post("/api/auth/reset-password")
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
"""Complete a reset using the emailed token. The token carries the user's
token_version, and finishing a reset bumps it — so the link is single-use and
every existing session for that account is signed out."""
claims = auth.decode_reset_token(body.token or "")
if not claims:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
# A completed reset also clears any login lockout — the person has proven
# control of the mailbox, so there's nothing left to throttle.
user.failed_attempts = 0
user.locked_until = None
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
db.commit()
return {"ok": True}
@app.get("/api/auth/me")
def whoami(user: models.User = Depends(auth.get_current_user)):
"""Who is logged in. The frontend guard calls this on every page load.
@@ -883,22 +959,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu
return {"user": user.to_dict()}
@app.post("/api/auth/password")
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
if not auth.verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
db.commit()
db.refresh(user)
# Keep this session logged in by re-issuing a cookie carrying the new version.
auth.set_session_cookie(response, request, auth.create_token(user))
return {"ok": True}
# ── User administration ─────────────────────────────────────────────────────────
# Two kinds of caller reach these routes: an app admin, who manages every account,
# and a Project Super User, who manages the accounts on the projects they administer.
@@ -986,9 +1046,6 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
@app.post("/api/auth/users")
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
problem = auth.password_problem(body.password, body.username, body.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
allowed = grantable_roles(actor)
if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
@@ -1020,7 +1077,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
username=body.username.strip(),
email=body.email.strip(),
full_name=body.full_name.strip(),
password_hash=auth.hash_password(body.password),
role=body.role,
project_role=body.project_role.strip()[:120],
)
@@ -1047,24 +1103,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
return directory_entry(db, u, actor)
@app.post("/api/auth/users/{user_id}/password")
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
problem = auth.password_problem(body.new_password, u.username, u.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
u.password_hash = auth.hash_password(body.new_password)
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
# An administrative password reset was the one user-account change that left no
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
detail={"by": "administrator"})
db.commit()
return {"ok": True}
@app.post("/api/auth/users/{user_id}/active")
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)

View File

@@ -1,8 +1,8 @@
"""Authentication for the Work Package Suite.
A self-contained username/password login. Passwords are stored only as bcrypt
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie
(`wp_session`). Because the token is signed and self-validating, there is no
Authentication is an LDAPS simple bind against the domain (D13); this module owns
everything *after* that. A successful sign-in issues a signed JWT that rides in an
HttpOnly cookie (`wp_session`). Because the token is signed and self-validating, there is no
server-side session store — every request is checked by verifying the cookie's
signature and expiry (see `auth_gate` and `get_current_user`).
@@ -12,6 +12,8 @@ Security model:
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session),
SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
HTTPS (detected via X-Forwarded-Proto behind NGINX).
• Roles are LOCAL. The directory supplies identity; this app decides what that
identity may do, which is why an existing admin keeps admin (D13 criterion 4).
• The signing secret comes from AUTH_SECRET_KEY. In production this MUST be
set; if it is missing we fall back to a random per-process key (which logs a
warning and invalidates every session on restart) so dev still works.
@@ -35,10 +37,11 @@ The user-administration SCOPE of a super user is worked out in server/app.py
(`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
There is no password and no password reset: D13 replaced local credentials with an
LDAPS bind (server/ldap_auth.py). People change their password with the domain, and
the login page points them at Okta. `token_version` survives as the
session-revocation mechanism — a role change or a deactivation must take effect on
sessions that have already been issued.
"""
import os
import secrets
@@ -46,7 +49,6 @@ import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
import jwt
from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func
@@ -61,8 +63,6 @@ COOKIE_NAME = "wp_session"
JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin"
@@ -121,29 +121,6 @@ def is_project_admin(user: "models.User") -> bool:
# same question used to exist here and silently disagreed with the scoped one, which
# locked per-project super users out of the routes they were entitled to.
# Password policy (shared by the API and the CLI).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# Paths under /api that do NOT require a session (login itself, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = {
@@ -184,22 +161,6 @@ def _load_secret() -> str:
SECRET_KEY = _load_secret()
# ── password hashing ──────────────────────────────────────────────────────────
def hash_password(plain: str) -> str:
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
def verify_password(plain: str, hashed: str) -> bool:
if not hashed:
return False
try:
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
except (ValueError, TypeError):
return False
# ── tokens ──────────────────────────────────────────────────────────────────
def create_token(user: "models.User") -> str:
now = datetime.now(timezone.utc)
@@ -227,33 +188,6 @@ def decode_token(token: str) -> Optional[dict]:
return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ────────────────────────────────────────────────────────────
def _is_https(request: Request) -> bool:
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,

View File

@@ -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-----

462
server/ldap_auth.py Normal file
View File

@@ -0,0 +1,462 @@
"""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 `<sAMAccountName>@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"
GROUP_CHECK_FAILED = "group_check_failed"
# AD returns these as `data <code>` 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,
GROUP_CHECK_FAILED)
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."""
from . import ldap_fake
if ldap_fake.is_active():
return ("*** FAKE DIRECTORY ACTIVE — passwords come from "
f"{ldap_fake.ENV_VAR}, NOT from the domain. Tests only. ***")
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:
"""Is `sam` in `group`, counting nested membership?
THE RETURN VALUE OF conn.search() IS NOT OPTIONAL READING. The connection is
built with raise_exceptions=False, so a search that FAILS returns False and
leaves conn.entries empty — which is byte-for-byte indistinguishable from "no
match" if you only look at conn.entries. An earlier version of this function did
exactly that, and every failure of the extensible-match filter presented to the
user as "you are not in the group" while they plainly were.
Two searches, in order, and the second one exists to catch the first being wrong:
1. AD's transitive matching rule (LDAP_MATCHING_RULE_IN_CHAIN). This is the
correct query — it walks nested groups, which plain memberOf does not.
2. If that matches nothing, a plain memberOf equality check for DIRECT
membership.
If (2) matches after (1) did not, the person IS a member and is let in — refusing
a real member is the worse error — but it is logged as a WARNING, because it means
the transitive rule is returning nothing and NESTED membership is silently not
working on this connection. That needs a human; it must not pass unnoticed.
"""
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)
esc_sam, esc_dn = escape_filter_chars(sam), escape_filter_chars(dn)
def _search(filt: str, label: str):
ok = conn.search(base_dn(), filt, search_scope=SUBTREE,
attributes=["sAMAccountName"], size_limit=1)
if not ok:
log.error("the %s membership search FAILED (not 'no match') for %r: %s | "
"filter=%s", label, sam, conn.result, filt)
raise LookupError(GROUP_CHECK_FAILED)
return bool(conn.entries)
if _search(f"(&(sAMAccountName={esc_sam})(memberOf:{NESTED_MEMBER_RULE}:={esc_dn}))",
"nested"):
return True
if _search(f"(&(sAMAccountName={esc_sam})(memberOf={esc_dn}))", "direct"):
log.warning(
"%r IS a direct member of %r, but AD's transitive matching rule "
"(%s) returned nothing for them. Allowing the sign-in — refusing a real "
"member is worse — but NESTED group membership is not working on this "
"connection and needs investigating.", sam, dn, NESTED_MEMBER_RULE)
return True
return False
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")
group = REQUIRED_GROUP if required_group is None else required_group
# Test seam (T10.7). Deliberately placed AFTER the empty-input guard above, so
# the anonymous-bind guard covers the fake path too — a fake that re-implemented
# it would let the real one rot without any test noticing. `is_active()` refuses
# whenever a non-SQLite DATABASE_URL is configured; see server/ldap_fake.py.
from . import ldap_fake
if ldap_fake.is_active():
ok, reason, attrs = ldap_fake.lookup(sam, password, group)
if not ok:
log.info("fake directory refused %r: %s", sam, reason)
return LdapResult(False, reason, "fake directory")
return LdapResult(True, OK, "fake directory", **attrs)
if not is_configured():
return LdapResult(False, UNCONFIGURED, describe())
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.warning("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.warning("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 as exc:
reason = str(exc) or GROUP_NOT_FOUND
return LdapResult(False, reason, f"group {group!r}: {reason}")
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

95
server/ldap_fake.py Normal file
View File

@@ -0,0 +1,95 @@
"""A fake directory, for tests only — D13 / T10.7.
`server/ldap_auth.py` normally opens an LDAPS connection to a domain controller.
Tests cannot: CI has no domain, and the browser checks launch the app as a
SUBPROCESS (`start_server` in tests/browser_check.py), so a monkeypatch in the
test process would never reach the code doing the authenticating. The seam has to
be configurable from the ENVIRONMENT, which is what this module is.
Set `WP_LDAP_FAKE_DIRECTORY` to a JSON object and `ldap_auth.verify()` answers
from it instead of touching the network:
{"root": {"password": "", "mail": "root@example.test",
"full_name": "Root", "groups": ["WP-Suite-Users"]}}
`groups` is a flat list of names the account is "in". Nested groups do not exist
here — the real `member_of` resolves a DN and uses AD's LDAP_MATCHING_RULE_IN_CHAIN,
and faking that faithfully would mean reimplementing AD. A test that cares about
nesting has to run against a real directory; this one is honest about being a
string comparison.
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
An environment variable that makes any password work is exactly the kind of thing
that escapes into production, and D13 removed every other way in — there is no
local password to fall back on and no break-glass, so a fake directory silently
active in production would be a total authentication bypass with nothing behind it.
So `is_active()` refuses whenever a real database is configured, using the same
test `auth._load_secret` uses to refuse an ephemeral signing key: a non-SQLite
`DATABASE_URL` means production, full stop. `ldap_auth.describe()` also shouts
when the fake is live, so the startup line can never be mistaken for a real one.
"""
import json
import logging
import os
from typing import Optional
log = logging.getLogger("wpsuite.ldap.fake")
ENV_VAR = "WP_LDAP_FAKE_DIRECTORY"
def _raw() -> str:
return os.getenv(ENV_VAR, "").strip()
def is_active() -> bool:
"""Whether the fake should answer. False in anything resembling production."""
if not _raw():
return False
# Imported lazily: server.db reads DATABASE_URL at import, and this module is
# imported from ldap_auth, which must stay importable on its own.
from .db import DATABASE_URL
if not str(DATABASE_URL).startswith("sqlite"):
log.error(
"%s is set but a non-SQLite DATABASE_URL is configured. REFUSING to use "
"the fake directory — this looks like production, and D13 leaves no "
"other way in, so honouring it would be an authentication bypass. "
"Unset %s.", ENV_VAR, ENV_VAR)
return False
return True
def directory() -> dict:
try:
data = json.loads(_raw())
if not isinstance(data, dict):
raise ValueError("top level must be an object")
return data
except Exception as exc: # noqa: BLE001 — a malformed fake must not look like a bad password
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
return {}
def lookup(username: str, password: str, required_group: Optional[str]) -> tuple:
"""Return (ok, reason, attrs). Mirrors what ldap_auth.verify() needs.
Deliberately does NOT re-check for an empty password: `verify()` guards that
before it ever gets here, and duplicating the check in the fake would let the
real guard rot without any test noticing.
"""
people = directory()
who = people.get(username) or people.get(username.lower())
if not isinstance(who, dict) or password != who.get("password"):
return (False, "bad_credentials", {})
if required_group:
groups = who.get("groups") or []
if required_group not in groups:
return (False, "not_in_group", {})
return (True, "ok", {
"sam": who.get("sam") or username,
"mail": who.get("mail", ""),
"full_name": who.get("full_name", ""),
"upn": who.get("upn", ""),
})

View File

@@ -1,21 +1,42 @@
"""Command-line user management for the Work Package Suite.
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
existing admin, so you have to bootstrap one here), and for occasional account
maintenance from a shell on the server.
Accounts are not created here any more. D13 provisions them on first successful
sign-in, so this tool exists to do the one thing the directory cannot decide:
assign the app's PERMISSIONS role. The directory supplies identity; this supplies
authorization.
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
and .env resolve the same way the API does:
python -m server.manage_users create-admin alice --name "Alice Smith"
python -m server.manage_users create bob --role user --name "Bob Jones"
python -m server.manage_users list
python -m server.manage_users reset-password alice
python -m server.manage_users promote alice # -> admin
python -m server.manage_users promote bob --role project_admin
python -m server.manage_users demote alice # -> project_user
python -m server.manage_users disable bob
python -m server.manage_users enable bob
If --password is omitted you'll be prompted (input is hidden). Passwords must be
at least 8 characters.
Run from the PROJECT ROOT (same place you run uvicorn) so the package imports and
.env resolve the way the API does.
`create-admin` and `create` are GONE (D14). They were redundant once accounts
provision themselves, and removing them closes a whole class of problem: every
row now originates from a successful bind, so a username can no longer be typed
in wrong and end up orphaned from the directory identity it was meant to match.
BOOTSTRAPPING THE FIRST ADMIN is therefore two steps, in this order:
1. Sign in to the app once. That provisions your account at project_user.
2. Run `promote <your-sAMAccountName>` here.
EVERY COMMAND THAT CHANGES ANYTHING REQUIRES A DOMAIN BIND (D14). Shell access
alone is no longer enough to mint an admin. Be clear about what that is and is
not worth: anyone with a shell on this container can still write to the `users`
table directly with psql or sqlite3, so this is defence in depth and — mostly —
ACCOUNTABILITY. Before D14 every role change made from a shell was invisible in
the audit trail while the same change through the console was recorded. Now both
are recorded, and both name a person.
The bind here deliberately does NOT apply the login group gate. If a mistyped
required group locks everyone out of the console, this tool has to still work —
otherwise the only way to fix the lockout is the only thing the lockout prevents.
`list` needs no credential, so an outage stays diagnosable.
"""
import argparse
import getpass
@@ -23,124 +44,194 @@ import sys
import uuid
from .db import SessionLocal, Base, engine
from . import models, auth
from . import models, auth, ldap_auth
def _gen_id() -> str:
return f"user_{uuid.uuid4().hex[:12]}"
def _gen_id(prefix: str = "user") -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}"
def _prompt_password(provided: str | None, username: str = "") -> str:
pw = provided
def _audit(db, actor: str, action: str, user: "models.User", detail: dict) -> None:
"""Append an audit row in the caller's transaction.
Written by hand rather than via app.py's log_event: importing that would drag
FastAPI and the entire application into a CLI startup for one INSERT.
"""
db.add(models.AuditLog(
id=_gen_id("ev"), actor=actor, action=action, entity_type="user",
entity_id=user.id, summary=user.username, detail=detail,
))
def authenticate_operator() -> str:
"""Prompt for a domain credential, bind, and return the operator's sAMAccountName.
Exits on failure — a command that changes a role must not proceed unauthenticated.
The password is only ever read from a hidden prompt: there is no --password flag,
because that would put a live domain password into shell history and into the
output of `ps` for every other user on the box.
"""
if not ldap_auth.is_configured():
sys.exit(f"Cannot authenticate: {ldap_auth.describe()}\n"
f"This command needs a domain bind. Fix the LDAP configuration first.")
who = input("Your domain username: ").strip()
if not who:
sys.exit("Cancelled.")
pw = getpass.getpass("Your domain password: ")
if not pw:
pw = getpass.getpass("New password: ")
confirm = getpass.getpass("Confirm password: ")
if pw != confirm:
sys.exit("Passwords do not match.")
problem = auth.password_problem(pw, username)
if problem:
sys.exit(problem)
return pw
sys.exit("Cancelled.")
# required_group="" on purpose: see the module docstring. The login group must
# not be able to lock an operator out of the tool that fixes the login group.
result = ldap_auth.verify(who, pw, required_group="")
if not result.ok:
# SAY WHY. The /api/auth/login endpoint deliberately returns one generic
# message so an unauthenticated caller cannot enumerate accounts; that
# reasoning does NOT transfer here. This is a local tool, the operator is
# the account holder, and there is nobody to leak to — so withholding the
# AD sub-code only makes a failure undiagnosable. An earlier version of
# this function printed "Authentication failed." and nothing else.
print(f"Authentication failed: {result.reason}{result.detail}", file=sys.stderr)
print(f" bind attempted as : {ldap_auth.normalize_username(who)}@{ldap_auth.DOMAIN}",
file=sys.stderr)
print(f" server : ldaps://{ldap_auth.HOST}:{ldap_auth.PORT}", file=sys.stderr)
print(f" password length : {len(pw)} characters", file=sys.stderr)
if result.reason == ldap_auth.BAD_CREDENTIALS:
print(" The sub-code above is AD's own reason: 52e = wrong password, "
"775 = account locked out, 532 = password expired, "
"533 = account disabled, 525 = no such user.", file=sys.stderr)
print(" A 525 with a password you know is correct means the BIND NAME is "
"wrong, not the password. This binds as <sAMAccountName>@LDAP_DOMAIN, "
"which only works where that matches your real UPN suffix — set "
"LDAP_DOMAIN to the UPN suffix if yours differs from the AD DNS name.",
file=sys.stderr)
sys.exit(1)
print(f"Authenticated as {result.sam}.")
return result.sam
def cmd_create(args, role: str | None = None) -> None:
role = role or args.role
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
# documented one-liners keep working; anything else has to be a current role.
if role == "user":
role = auth.ROLE_PROJECT_USER
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
if auth.find_user(db, args.username):
sys.exit(f"A user named '{args.username}' already exists.")
u = models.User(
id=_gen_id(),
username=args.username.strip(),
full_name=(args.name or "").strip(),
email=(args.email or "").strip(),
password_hash=auth.hash_password(pw),
role=role,
)
db.add(u)
db.commit()
print(f"Created {role}: {u.username} (id={u.id})")
def _load(db, username: str) -> "models.User":
u = auth.find_user(db, username)
if not u:
sys.exit(f"No account named '{username}'. Accounts are created on first "
f"sign-in — has this person signed in yet? `list` shows who exists.")
return u
def cmd_list(args) -> None:
"""Read-only, and deliberately needs no credential: during an outage this is
how you find out what the app thinks the world looks like."""
with SessionLocal() as db:
rows = db.query(models.User).order_by(models.User.username).all()
if not rows:
print("No users yet. Create one with: create-admin <username>")
print("No accounts yet. They are created on first successful sign-in.")
return
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'LAST LOGIN':<22}{'NAME'}")
for u in rows:
last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never"
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
f"{('yes' if u.is_active else 'no'):<8}{last:<22}{u.full_name}")
def cmd_reset_password(args) -> None:
pw = _prompt_password(getattr(args, "password", None), args.username)
def _set_role(username: str, role: str) -> None:
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
operator = authenticate_operator()
with SessionLocal() as db:
u = auth.find_user(db, args.username)
if not u:
sys.exit(f"No user named '{args.username}'.")
u.password_hash = auth.hash_password(pw)
u = _load(db, username)
old = auth.normalize_role(u.role)
if old == role:
print(f"{u.username} is already {role}. Nothing to do.")
return
# The last-admin guard, matching set_user_role in app.py: an app with no
# admin cannot be administered, and there is no password login left to
# recover through.
if old == auth.ROLE_ADMIN and role != auth.ROLE_ADMIN:
others = db.query(models.User).filter(
models.User.role == auth.ROLE_ADMIN,
models.User.id != u.id,
models.User.is_active.is_(True),
).count()
if not others:
sys.exit("Refusing: that is the last active admin account. Promote "
"someone else first.")
detail = {"from": old, "to": role, "via": "manage_users"}
u.role = role
# Mirrors set_user_role: an admin already reaches every project, so the
# default-member flag would sit there doing nothing and spring back to life
# on demotion.
if role == auth.ROLE_ADMIN and (u.auto_add_projects or u.auto_add_role):
u.auto_add_projects = False
u.auto_add_role = ""
detail["auto_add_cleared"] = True
# NOTE: unlike the API, this permits changing your OWN role. The endpoint
# forbids it to stop an admin locking themselves out of the console; here it
# is the entire bootstrap path — sign in, then promote yourself.
if u.username.lower() == operator.lower():
detail["self"] = True
_audit(db, operator, "role_changed", u, detail)
db.commit()
print(f"Password reset for {u.username}.")
print(f"{u.username}: {old} -> {role}")
def cmd_promote(args) -> None:
_set_role(args.username, args.role)
def cmd_demote(args) -> None:
_set_role(args.username, auth.ROLE_PROJECT_USER)
def _set_active(username: str, active: bool) -> None:
operator = authenticate_operator()
with SessionLocal() as db:
u = auth.find_user(db, username)
if not u:
sys.exit(f"No user named '{username}'.")
u = _load(db, username)
if bool(u.is_active) == active:
print(f"{u.username} is already {'enabled' if active else 'disabled'}.")
return
u.is_active = active
# Disabling has to take effect on sessions already issued, and role reads go
# through the database on every request — but token_version is what get_current_user
# checks, so bump it to sign them out now rather than at session expiry.
u.token_version = (u.token_version or 0) + 1
_audit(db, operator, "user_active_changed", u,
{"is_active": active, "via": "manage_users"})
db.commit()
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
def main() -> None:
# Ensure the users table exists even on a fresh database.
# Ensure tables exist on a fresh local database (SQLite dev). Production owns
# its schema through alembic.
Base.metadata.create_all(bind=engine)
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
p = argparse.ArgumentParser(
prog="manage_users",
description="Work Package Suite user management. Accounts are created on "
"first sign-in (D13); this assigns roles.")
sub = p.add_subparsers(dest="cmd", required=True)
def add_create(name, help_):
sp = sub.add_parser(name, help=help_)
sp.add_argument("username")
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
sp.add_argument("--name", default="", help="full name")
sp.add_argument("--email", default="")
return sp
sub.add_parser("list", help="list all accounts (no credential needed)")
add_create("create-admin", "create an admin account")
c = add_create("create", "create an account")
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
help="permissions role ('user' is the legacy name for project_user)")
pr = sub.add_parser("promote", help="raise an account's permissions role (needs a domain bind)")
pr.add_argument("username", help="the person's sAMAccountName")
pr.add_argument("--role", default=auth.ROLE_ADMIN, choices=list(auth.ROLES),
help="target role (default: admin)")
sub.add_parser("list", help="list all accounts")
dm = sub.add_parser("demote", help=f"set an account back to {auth.ROLE_PROJECT_USER}")
dm.add_argument("username", help="the person's sAMAccountName")
rp = sub.add_parser("reset-password", help="reset a user's password")
rp.add_argument("username")
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
dp = sub.add_parser("disable", help="disable an account (blocks login)")
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
dp.add_argument("username")
ep = sub.add_parser("enable", help="re-enable an account")
ep.add_argument("username")
args = p.parse_args()
if args.cmd == "create-admin":
cmd_create(args, role="admin")
elif args.cmd == "create":
cmd_create(args)
elif args.cmd == "list":
if args.cmd == "list":
cmd_list(args)
elif args.cmd == "reset-password":
cmd_reset_password(args)
elif args.cmd == "promote":
cmd_promote(args)
elif args.cmd == "demote":
cmd_demote(args)
elif args.cmd == "disable":
_set_active(args.username, False)
elif args.cmd == "enable":

View File

@@ -142,8 +142,14 @@ class WorkPackage(Base):
class User(Base):
"""A login account. Passwords are never stored in the clear — only a bcrypt
hash (see server/auth.py). `username` is what people sign in with.
"""A login account. NO PASSWORD IS STORED — D13 moved authentication to an
LDAPS bind against the domain (see server/ldap_auth.py), and the
`password_hash` column was dropped. `username` is the sAMAccountName people
sign in with; an account is created on first successful sign-in if it does not
already exist.
`is_active` is LOCAL and overrides the directory: clearing it revokes access to
this app without touching the domain account.
Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app.
@@ -159,7 +165,6 @@ class User(Base):
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="")
password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="")
@@ -182,12 +187,14 @@ class User(Base):
# Online-guessing throttle (see login()): consecutive failures + a lockout window.
failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# Bumped to invalidate all existing sessions for this user (e.g. on a password
# change). The value is embedded in the JWT and re-checked on every request.
# Bumped to invalidate all existing sessions for this user. Password changes no
# longer exist (D13), but a role change or a deactivation still has to take
# effect on live sessions. The value is embedded in the JWT and re-checked on
# every request.
token_version: Mapped[int] = mapped_column(Integer, default=0)
def to_dict(self) -> dict:
"""Public view of a user — NEVER includes the password hash."""
"""Public view of a user."""
return {
"id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role,

View File

@@ -49,7 +49,8 @@ DEFAULTS = {
# Settings the app needs before anyone is signed in, or that carry no secrets and
# are safe for any authenticated user to read (feature flags + localization
# defaults + whether self-service password reset can work at all).
# defaults). Self-service password reset is gone with D13 — the login page links to
# Okta instead, so there is nothing left for the client to feature-detect.
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
@@ -83,13 +84,9 @@ def public_settings(db: Session) -> dict:
def app_flags(db: Session) -> dict:
"""Feature flags for any signed-in user (no secrets, no SMTP detail).
`password_reset_enabled` tells the login page whether a self-service reset can
actually deliver mail — there's no point offering the link otherwise."""
"""Feature flags for any signed-in user (no secrets, no SMTP detail)."""
s = get_settings(db)
out = {k: s.get(k) for k in PUBLIC_KEYS}
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
return out
return {k: s.get(k) for k in PUBLIC_KEYS}
def smtp_ready(s: dict) -> bool:
@@ -124,22 +121,6 @@ def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
srv.send_message(msg)
def send_now(db: Session, to_addr: str, subject: str, body: str) -> bool:
"""Send one email immediately, outside the outbox. Used for password resets —
a reset link must never sit in a queue, and it must not be persisted in the
notifications table where an admin could read it and take over the account.
Returns True if it went out."""
s = get_settings(db)
if not (s.get("email_enabled") and smtp_ready(s) and to_addr):
return False
try:
send_email(s, to_addr, subject, body)
return True
except Exception as e: # noqa: BLE001 — never surface SMTP detail to the caller
log.warning("password-reset email to %s failed: %s", to_addr, e)
return False
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +

View File

@@ -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)

View File

@@ -26,6 +26,7 @@ browser found, or the server would not start). 2 is distinct on purpose: "I coul
not test this" is not the same answer as "this is broken".
"""
import argparse
import json
import os
import subprocess
import sys
@@ -86,7 +87,7 @@ def seed(db_path):
def mk(username, role):
db.add(models.User(id="user_" + username, username=username,
email=f"{username}@example.test", full_name=username.title(),
password_hash=auth.hash_password(PW), role=role))
role=role))
mk("root", auth.ROLE_ADMIN)
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
@@ -150,10 +151,33 @@ def seed(db_path):
for u in db.query(models.User).all()}
# D13 / T10.7. The app authenticates by binding to a domain controller, which no
# test can reach, and start_server launches it as a SUBPROCESS — so a monkeypatch
# here would never reach the code doing the authenticating. server/ldap_fake.py
# reads this instead, and refuses to work against a non-SQLite database.
#
# Most checks never sign in (seed() mints tokens with auth.create_token and sets
# the cookie directly), so this matters only where the login FORM is driven —
# url_state_check's deep-link-through-login case. It is set for every server here
# anyway so that a test which starts signing in later does not fail mysteriously.
FAKE_DIRECTORY = json.dumps({
u: {"password": PW, "mail": f"{u}@example.test", "full_name": u.title(),
"groups": ["WP-Suite-Users"]}
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
})
def start_server(port, db_path):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
env["WP_LDAP_FAKE_DIRECTORY"] = FAKE_DIRECTORY
# SET empty, never pop: server/db.py calls load_dotenv() at import and
# python-dotenv only skips keys already present in os.environ, so a popped
# variable comes back from the developer's .env inside the subprocess. An empty
# string is "present" and therefore wins. The fake grants "WP-Suite-Users" to
# everyone; a test asserting the group gate belongs in ldap_auth_check.
env["LDAP_REQUIRED_GROUP"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"],

View File

@@ -91,31 +91,6 @@ def main():
chk("the console booted with a user table",
page.eval("!!document.querySelector('table')"))
page.eval("void resetPw('user_pat','pat')")
time.sleep(0.4)
chk("the reset prompt is the kit's modal, open, focused at the input",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return !!o && o.classList.contains('open')"
" && document.activeElement.id==='wp-dlg-input'; })()"))
page.eval("document.getElementById('wp-dlg-input').value='short';"
"document.getElementById('wp-dlg-ok').click()")
chk("a short password is refused AT the input - dialog stays, error says why",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return o.classList.contains('open')"
" && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()"))
page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';"
"document.getElementById('wp-dlg-ok').click()")
time.sleep(1.2)
chk("a good answer closes the dialog and the server accepts it",
page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')"))
chk("...announced through the kit's toast (role=status)",
page.eval("(() => { const t=document.getElementById('toast');"
" return !!t && t.getAttribute('role')==='status'"
" && /Password reset for pat/.test(t.textContent); })()"))
st, _ = api(base, "/api/auth/login", "x", "POST",
{"username": "pat", "password": "CorrectHorseBattery10"})
chk("...and the new password actually works", st == 200, st)
print("\n3. destroy needs a real yes")
page.eval("void deleteUser('user_bob','bob')")
time.sleep(0.4)

View File

@@ -55,8 +55,7 @@ def seed_empty(db_path):
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
db.add(models.User(id="user_new", username="new", email="new@example.test",
full_name="New Starter", password_hash=auth.hash_password(PW),
role=auth.ROLE_ADMIN))
full_name="New Starter", role=auth.ROLE_ADMIN))
db.commit()
return {u.username: auth.create_token(u) for u in db.query(models.User).all()}

419
tests/ldap_auth_check.py Normal file
View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""Does domain authentication hold its guarantees? — D13 / T10.7.
Covers the four things `docs/waves/decisions-2026-08-21.md` calls non-negotiable,
plus the two provisioning rules that decide whether an existing admin survives the
switch. These are the failure modes where the app still *looks* fine:
1. An empty password must not reach bind(). In LDAP a 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.
2. TLS must be CERT_REQUIRED with an explicit CA file. CERT_NONE still encrypts,
so it fails silently; what it loses is the ability to tell a real DC from
someone harvesting domain passwords.
3. Group membership must be evaluated through AD's nested-group matching rule.
Plain memberOf is direct membership only and wrongly refuses real people.
4. The fake directory must be impossible to select against a real database.
5. A refused sign-in must create no account.
6. An existing admin must still be an admin afterwards.
Self-contained: throwaway SQLite + its own uvicorn. No browser, no domain — the
fake directory (server/ldap_fake.py) stands in for the DC.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import ssl
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cdp import free_port # noqa: E402
_PASS, _FAIL = [], []
PW = "CorrectHorseBattery9"
GROUP = "WP-Suite-Users"
SECRET = "ldap-auth-check-not-for-production"
os.environ.setdefault("AUTH_SECRET_KEY", SECRET)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def chk(label, ok, detail=""):
(_PASS if ok else _FAIL).append(label)
print(f" {'PASS' if ok else 'FAIL'} {label}" + ("" if ok else f" {detail}"))
def post(base, path, payload):
req = urllib.request.Request(base + path, method="POST",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode() or "{}")
except Exception:
return e.code, {}
def get(base, path, token):
req = urllib.request.Request(base + path, headers={"Cookie": f"wp_session={token}"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {}
def post_as(base, path, token, payload):
req = urllib.request.Request(base + path, method="POST",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json",
"Cookie": f"wp_session={token}"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {}
def start(port, db_path, fake, required_group=""):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env["AUTH_SECRET_KEY"] = SECRET
env["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(fake)
# SET it empty, never pop it. server/db.py calls load_dotenv() at import, and
# python-dotenv only skips a key that is already present in os.environ — so a
# popped variable is helpfully restored from the developer's .env inside the
# subprocess, and the test silently runs against the real required group.
# An empty string counts as present, so it wins.
env["LDAP_REQUIRED_GROUP"] = required_group or ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=ROOT)
for _ in range(160):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1):
return proc
except Exception:
if proc.poll() is not None:
return None
time.sleep(0.25)
proc.kill()
return None
def users_in(db_path):
"""Read the users table straight out of the given file.
Deliberately NOT via server.db.SessionLocal: that engine is bound from
DATABASE_URL when the module is first imported, so setting the env var later
keeps reading whichever database was configured first. Two assertions in this
file passed against the wrong file before that was noticed.
"""
import sqlite3
con = sqlite3.connect(db_path)
try:
try:
return {r[0]: r[1] for r in con.execute("select username, role from users")}
except sqlite3.OperationalError:
return {} # no users table yet
finally:
con.close()
def main():
print("1. the guards that do not need a server")
os.environ["DATABASE_URL"] = "sqlite:///./_ldapcheck_unit.db"
os.environ["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(
{"root": {"password": PW, "groups": [GROUP]}})
from server import ldap_auth
# 1 — the anonymous-bind guard. Connection is nulled so ANY call to bind()
# would raise: this proves the guard returns before the transport is touched,
# rather than merely that the result is a failure.
saved, ldap_auth.Connection = ldap_auth.Connection, None
try:
for label, u, p in [("an empty password", "root", ""),
("a whitespace-only password", "root", " "),
("an empty username", "", PW)]:
r = ldap_auth.verify(u, p, required_group="")
chk(f"{label} is refused without reaching bind()",
(not r.ok) and r.reason == ldap_auth.EMPTY_INPUT, r.reason)
finally:
ldap_auth.Connection = saved
# 2 — TLS configuration.
tls = ldap_auth._tls()
chk("TLS validate is CERT_REQUIRED", tls.validate == ssl.CERT_REQUIRED, tls.validate)
chk("...with an explicit CA file, not the system store",
bool(tls.ca_certs_file) and os.path.isfile(tls.ca_certs_file), tls.ca_certs_file)
src = open(os.path.join(ROOT, "server", "ldap_auth.py"), encoding="utf-8").read()
# Parse the module rather than grep it: the docstring names validate=ssl.CERT_NONE
# in order to explain why it must never be used, and a text search cannot tell
# that apart from an actual call. Walk every keyword argument called `validate`
# and check what it is really set to.
import ast as _ast
bad = []
for node in _ast.walk(_ast.parse(src)):
if isinstance(node, _ast.Call):
for kw in node.keywords:
if kw.arg == "validate":
name = (kw.value.attr if isinstance(kw.value, _ast.Attribute)
else getattr(kw.value, "id", ""))
if name != "CERT_REQUIRED":
bad.append(f"line {node.lineno}: validate={name or '?'}")
chk("every validate= in the module is CERT_REQUIRED (AST, not grep)",
not bad, "; ".join(bad))
# 3 — nested groups. The fake cannot model AD nesting (it is a string list), so
# what is asserted is that the REAL path builds AD's transitive matching rule
# into its filter. A test that truly exercises nesting needs a real directory.
chk("membership uses AD's nested matching rule, not plain memberOf",
ldap_auth.NESTED_MEMBER_RULE == "1.2.840.113556.1.4.1941"
and "memberOf:{NESTED_MEMBER_RULE}:=" in src,
"the transitive matching rule is not in the search filter")
# 4 — the production guard on the fake, in a subprocess because DATABASE_URL is
# read at import time.
out = subprocess.run(
[sys.executable, "-c",
"from server import ldap_fake; print(ldap_fake.is_active())"],
cwd=ROOT, capture_output=True, text=True,
env={**os.environ,
"DATABASE_URL": "postgresql+psycopg://u:p@localhost:5432/db",
"AUTH_SECRET_KEY": "x",
"WP_LDAP_FAKE_DIRECTORY": json.dumps({"root": {"password": PW}})})
chk("the fake directory REFUSES to work against a non-SQLite database",
out.stdout.strip() == "False", out.stdout.strip() or out.stderr[-200:])
print("\n2. sign-in, against a server")
db_fd, db_path = tempfile.mkstemp(suffix=".db"); os.close(db_fd)
port = free_port()
fake = {"root": {"password": PW, "mail": "root@example.test",
"full_name": "Root Person", "groups": [GROUP]},
"outsider": {"password": PW, "mail": "outsider@example.test",
"full_name": "Out Sider", "groups": ["SomeOtherGroup"]}}
server = start(port, db_path, fake, required_group=GROUP)
if server is None:
print("the test server would not start.")
return 2
base = f"http://127.0.0.1:{port}"
try:
st, _ = post(base, "/api/auth/login", {"username": "root", "password": PW})
chk("a correct password signs in", st == 200, st)
chk("...and provisioned the account at project_user",
users_in(db_path).get("root") == "project_user", users_in(db_path))
st, body = post(base, "/api/auth/login", {"username": "root", "password": "wrong-" + PW})
chk("a wrong password is refused", st == 401, st)
chk("...with a message that does not say why",
"password" in (body.get("detail") or "").lower()
and "expired" not in (body.get("detail") or "").lower(), body)
st, _ = post(base, "/api/auth/login", {"username": "root", "password": ""})
chk("a blank password is refused at the endpoint too", st == 401, st)
before = set(users_in(db_path))
st, _ = post(base, "/api/auth/login", {"username": "outsider", "password": PW})
chk("a correct password OUTSIDE the required group is refused", st == 401, st)
chk("...and no account was created for them",
set(users_in(db_path)) == before, set(users_in(db_path)) - before)
st, _ = post(base, "/api/auth/login", {"username": "nobody", "password": PW})
chk("an unknown account is refused and creates nothing",
st == 401 and "nobody" not in users_in(db_path), st)
finally:
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("")
print("4. local state overrides the directory, and the throttle protects it")
db_fd3, db3 = tempfile.mkstemp(suffix=".db"); os.close(db_fd3)
import sqlalchemy as _sa0
from server.db import Base as _B0
from server import models as _m0 # noqa: F401
eng0 = _sa0.create_engine("sqlite:///" + db3.replace("\\", "/"))
_B0.metadata.create_all(bind=eng0)
with eng0.begin() as con:
con.execute(_sa0.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
"('user_root','root','','Root','project_user',0,0,0,'','','',0,'',"
"datetime('now'),datetime('now'))")) # is_active = 0
eng0.dispose()
port3 = free_port()
server3 = start(port3, db3, fake, required_group=GROUP)
if server3 is None:
print("the third test server would not start.")
return 2
b3 = f"http://127.0.0.1:{port3}"
try:
st, _ = post(b3, "/api/auth/login", {"username": "root", "password": PW})
chk("a disabled local account is refused even though the bind succeeds",
st == 403, st)
# The throttle. AUTH_MAX_ATTEMPTS is 2, and its whole purpose is that failures
# are real domain binds counting against the AD lockout policy — so it has to
# stop CALLING the directory, not merely refuse. Proving that: burn the budget
# with wrong passwords, then present the CORRECT one. A 429 for a credential
# that would otherwise succeed is only possible if the throttle runs before the
# directory is consulted.
codes = [post(b3, "/api/auth/login",
{"username": "outsider", "password": "wrong"})[0] for _ in range(3)]
chk("the attempt budget is spent and the next try is throttled",
codes[-1] == 429, codes)
st, _ = post(b3, "/api/auth/login", {"username": "outsider", "password": PW})
chk("...and a CORRECT password is still refused while throttled, proving the "
"directory is never reached", st == 429, st)
chk("...while another account is unaffected (the budget is per-username)",
post(b3, "/api/auth/login", {"username": "root", "password": PW})[0] == 403, "")
finally:
server3.kill()
try:
server3.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n3. an existing admin survives the switch")
db_fd2, db2 = tempfile.mkstemp(suffix=".db"); os.close(db_fd2)
# Build the schema with a NEW engine bound to this file — see users_in().
import sqlalchemy as _sa
from server.db import Base
from server import models # noqa: F401
eng2 = _sa.create_engine("sqlite:///" + db2.replace("\\", "/"))
Base.metadata.create_all(bind=eng2)
with eng2.begin() as con:
con.execute(_sa.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
"('user_root','root','','Set By Hand','admin',1,0,0,'','','',0,'',"
"datetime('now'),datetime('now'))"))
eng2.dispose()
port2 = free_port()
server2 = start(port2, db2, fake, required_group=GROUP)
if server2 is None:
print("the second test server would not start.")
return 2
try:
st, body = post(f"http://127.0.0.1:{port2}", "/api/auth/login",
{"username": "root", "password": PW})
chk("the pre-existing admin signs in", st == 200, st)
chk("...and is STILL an admin (D13 criterion 4)",
users_in(db2).get("root") == "admin", users_in(db2))
chk("...and their locally-set name was not overwritten by the directory",
(body.get("user") or {}).get("full_name") == "Set By Hand", body.get("user"))
chk("...and no duplicate account appeared",
len(users_in(db2)) == 1, users_in(db2))
finally:
server2.kill()
try:
server2.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("")
print("5. the console's own routes, and what token_version actually does")
db_fd4, db4 = tempfile.mkstemp(suffix=".db"); os.close(db_fd4)
import sqlalchemy as _sa1
from server.db import Base as _B1
from server import models as _m1, auth as _auth # noqa: F401
url4 = "sqlite:///" + db4.replace("\\", "/")
eng1 = _sa1.create_engine(url4)
_B1.metadata.create_all(bind=eng1)
with eng1.begin() as con:
for uid, un, role in [("user_boss", "root", "admin"),
("user_pat", "pat", "project_user")]:
con.execute(_sa1.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
f"('{uid}','{un}','','{un}','{role}',1,0,0,'','','',0,'',"
"datetime('now'),datetime('now'))"))
class _U:
pass
def _tok(uid, un, role):
u = _U(); u.id = uid; u.username = un; u.role = role; u.token_version = 0
return _auth.create_token(u)
boss_tok = _tok("user_boss", "root", "admin")
pat_tok = _tok("user_pat", "pat", "project_user")
port4 = free_port()
server4 = start(port4, db4, fake, required_group="")
if server4 is None:
print("the fourth test server would not start.")
return 2
b4 = f"http://127.0.0.1:{port4}"
try:
st, body = get(b4, "/api/auth/users", boss_tok)
chk("an admin can list accounts", st == 200, st)
post(b4, "/api/auth/login", {"username": "outsider", "password": PW})
st, body = get(b4, "/api/auth/users", boss_tok)
rows = body if isinstance(body, list) else (body.get("users") or body.get("items") or [])
names = [u.get("username") for u in rows if isinstance(u, dict)]
chk("a just-provisioned account appears in the Admin console list",
"outsider" in names, names)
# Exactly the request users.html sends — D13 criterion 4.
st, _ = post_as(b4, "/api/auth/users/user_pat/role", boss_tok, {"role": "admin"})
chk("granting admin through the console route succeeds", st == 200, st)
chk("...and the role really changed", users_in(db4).get("pat") == "admin", users_in(db4))
# token_version. NOTHING in app.py bumps it any more: is_active and role are
# re-read from the database every request, so both take effect at once without
# it. What it still does is invalidate an ALREADY-ISSUED cookie, which is what
# manage_users does on disable. Bump it directly and prove the effect.
eng2 = _sa1.create_engine(url4)
with eng2.begin() as con:
con.execute(_sa1.text("update users set token_version = 1 where id='user_pat'"))
eng2.dispose()
st, _ = get(b4, "/api/auth/me", pat_tok)
chk("bumping token_version invalidates a cookie already issued", st == 401, st)
st, _ = get(b4, "/api/auth/me", boss_tok)
chk("...and leaves every other session alone", st == 200, st)
st, body = post(b4, "/api/auth/login", {"username": "root", "password": "wrong"})
blob = json.dumps(body).lower()
chk("no AD sub-code leaks into a response body",
not any(c in blob for c in ("52e", "525", "532", "533", "775", "data ")), body)
from server import ldap_auth as _la
chk("...though _err49 does parse one when AD sends it",
"52e" in _la._err49({"message": "80090308: LdapErr: DSID-0C0903A9, comment: "
"AcceptSecurityContext error, data 52e, v4563"}))
finally:
eng1.dispose()
server4.kill()
try:
server4.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n" + "-" * 54)
print(f"{len(_PASS)}/{len(_PASS) + len(_FAIL)} checks passed.")
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(f"could not run: {type(exc).__name__}: {exc}")
sys.exit(2)

View File

@@ -54,20 +54,6 @@ def main():
chk("locations and materials are both instances of it - not a copy beside it",
"locList = WPListImport(" in suite and "matList = WPListImport(" in suite
and "function locImport(dryRun){ locList.importText" in suite)
# The 2026-08-21 outage, pinned: a Boolean server_default of sa.text('1')
# passes on SQLite (which coerces 1) and crash-loops Postgres at deploy
# (DatatypeMismatch). Every migration must say sa.true()/sa.false().
import re as _re
bad = []
vdir = os.path.join(ROOT, "server", "alembic", "versions")
for fn in sorted(os.listdir(vdir)):
if not fn.endswith(".py"):
continue
for ln in open(os.path.join(vdir, fn), encoding="utf-8"):
if "Boolean" in ln and "server_default" in ln and not _re.search(r"server_default=sa\.(true|false)\(\)", ln):
bad.append("%s: %s" % (fn, ln.strip()[:90]))
chk("no migration gives a Boolean a non-portable server_default "
"(sa.true()/sa.false() only)", not bad, ascii_(bad[:3]))
model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")]
cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)

View File

@@ -187,9 +187,13 @@ def main():
break
time.sleep(0.3)
settle(page, 1.2)
# NOT `"wp-creation-index.html" in location.href` — that string is in the
# ?next= parameter too, so the check passed while still sitting on
# login.html with the sign-in rejected. Assert we actually LEFT the
# login page (D13/T10.7: it caught nothing when the bind started failing).
href = page.eval("location.href")
chk("signing in continues to the requested page, not the home page",
"wp-creation-index.html" in page.eval("location.href"),
page.eval("location.href"))
"login.html" not in href and "wp-creation-index.html" in href, href)
for _ in range(30):
if page.eval("!!window.wpCreatorReady"):
break