Compare commits

...

10 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
12 changed files with 432 additions and 69 deletions

View File

@@ -91,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
@@ -303,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

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

@@ -601,8 +601,12 @@ deliberately deferred.
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:** pop `MICRON_DB_URL` from the env for that server, exactly as `start_server`
now pops `LDAP_REQUIRED_GROUP` for the same reason (`T10.7`).
- **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.
@@ -623,3 +627,25 @@ deliberately deferred.
- **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

@@ -12,6 +12,14 @@ with their Windows password instead of an app password, the "Forgot password?" l
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.
@@ -51,15 +59,15 @@ across resolved addresses, because round-robin will hand out a rebooting DC's ad
**Done when:**
- [ ] `ldap3` is pinned to an exact version in `requirements.txt`
- [ ] an empty or whitespace-only password returns failure **without calling `bind()`**
- [ ] an empty username returns failure without calling `bind()`
- [ ] `Tls` is constructed with `validate=ssl.CERT_REQUIRED` and an explicit `ca_certs_file`
- [ ] no code path sets `CERT_NONE`, and none falls back to the system trust store
- [ ] `member_of` returns true for an account in a **nested** child of the required group
- [ ] a bind against `192.168.3.37` (raw IP) fails hostname validation rather than silently passing
- [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)`
- [ ] the module imports cleanly with no LDAP env set (unconfigured is a first-class state, as with `MICRON_DB_URL`)
- [x] the module imports cleanly with no LDAP env set (unconfigured is a first-class state, as with `MICRON_DB_URL`)
---
@@ -89,14 +97,15 @@ misconfigured deploy is indistinguishable from a forgotten password at the login
**Done when:**
- [ ] a correct domain password signs in and sets the session cookie
- [ ] a wrong password is refused with the generic message
- [ ] a blank password is refused (guards `T10.1` from the caller's side too)
- [ ] a user not in the required group is refused even though the bind succeeded
- [ ] `is_active = false` locally still refuses, independent of the directory
- [ ] the local throttle trips below the domain lockout threshold and stops calling the DC
- [ ] no response body distinguishes "no such user" from "wrong password"
- [ ] error-49 sub-codes appear in the log and nowhere in any response
- [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
---
@@ -131,12 +140,12 @@ is criterion 4 and must keep working.
**Done when:**
- [ ] `grep -rn "password_hash\|hash_password\|verify_password\|password_problem" server/` returns nothing outside the migration
- [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`)
- [ ] the migration's `downgrade()` recreates the column nullable, not `NOT NULL` — there are no hashes to put back
- [ ] `token_version` still invalidates sessions, exercised by a role change
- [ ] `manage_users.py list`, `disable`, `enable` still work; `reset-password` is gone
- [ ] `python -m server.manage_users create-admin <u>` creates an admin with no password prompt
- [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`
---
@@ -176,15 +185,15 @@ creating it is exactly the kind of event that record exists for.
**Done when:**
- [ ] an unknown username with a valid bind and group membership gets a `users` row at `project_user`
- [ ] `full_name` and `email` are populated from the directory on creation
- [ ] an existing `admin` signing in is still `admin` afterwards — asserted, not assumed
- [ ] an existing account with a locally-set `full_name` does not have it overwritten
- [ ] a JIT account has NO `ProjectMember` rows and sees no projects
- [ ] the new account appears in the Admin console user list so access can be granted
- [ ] each JIT creation writes an `AuditLog` row
- [ ] a failed bind creates **no** row
- [ ] a bind that succeeds but fails the group check creates **no** row
- [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
---
@@ -267,16 +276,16 @@ Keep the role-granting controls exactly as they are. That is criterion 4.
**Done when:**
- [ ] `grep -rn "forgot\|reset-password\|new-password" html/` returns nothing but prose
- [ ] "Forgot password?" opens `https://primecontrols.okta.com/` in a new tab
- [ ] `#forgot-link` has NO click handler (a `preventDefault()` would swallow the navigation)
- [ ] a 503 from the login endpoint says sign-in is unavailable, not that the password is wrong
- [ ] the sign-in form still submits, and a failure still announces through `role="alert"` (`login.html` already does this correctly — do not regress it)
- [ ] the password field says which password to enter
- [ ] no dead `<a href="#">` or handler remains for a removed view
- [ ] granting admin to an existing user still works from the console
- [ ] exercised at 390px and at 1440px, screenshots in the PR
- [ ] no raw hex added to any stylesheet (the token rule)
- [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
---

View File

@@ -138,6 +138,37 @@ 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

@@ -10,6 +10,7 @@ Interactive docs: http://<host>/api/docs
"""
import base64
import logging
from contextlib import asynccontextmanager
import os
import re
import uuid
@@ -44,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,
@@ -821,7 +845,11 @@ def login(body: LoginIn, request: Request, response: Response, db: Session = Dep
detail="Sign-in is temporarily unavailable. Contact IT.")
if not result.ok:
log.info("sign-in refused for %r (%s: %s)", sam, result.reason, result.detail)
# 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

View File

@@ -117,6 +117,7 @@ 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
@@ -156,7 +157,8 @@ class LdapResult:
"""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)
return self.reason in (UNCONFIGURED, UNREACHABLE, UNTRUSTED, GROUP_NOT_FOUND,
GROUP_CHECK_FAILED)
def base_dn(domain: Optional[str] = None) -> str:
@@ -268,17 +270,57 @@ def _resolve_group_dn(conn, group: str) -> Optional[str]:
def member_of(conn, sam: str, group: str) -> bool:
"""Nested-group-aware membership test for an already-bound connection."""
"""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)
filt = (f"(&(sAMAccountName={escape_filter_chars(sam)})"
f"(memberOf:{NESTED_MEMBER_RULE}:={escape_filter_chars(dn)}))")
conn.search(base_dn(), filt, search_scope=SUBTREE,
attributes=["sAMAccountName"], size_limit=1)
return bool(conn.entries)
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:
@@ -330,7 +372,7 @@ def verify(username: str, password: str, required_group: Optional[str] = None) -
)
if not conn.bind():
detail = _err49(conn.result)
log.info("bind refused for %r: %s", sam, detail)
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
@@ -351,11 +393,12 @@ def verify(username: str, password: str, required_group: Optional[str] = None) -
if group:
try:
if not member_of(conn, sam, group):
log.info("bind succeeded for %r but the account is not in %r",
sam, group)
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:
return LdapResult(False, GROUP_NOT_FOUND, f"group {group!r} not found")
except LookupError as exc:
reason = str(exc) or GROUP_NOT_FOUND
return LdapResult(False, reason, f"group {group!r}: {reason}")
return LdapResult(
True, OK,

View File

@@ -172,9 +172,12 @@ def start_server(port, db_path):
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
# No required group: the fake grants "WP-Suite-Users" to everyone, and a test
# asserting the group gate belongs in ldap_auth_check where it can be explicit.
env.pop("LDAP_REQUIRED_GROUP", None)
# 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

@@ -39,6 +39,8 @@ from cdp import free_port # noqa: E40
_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__)))
@@ -61,15 +63,38 @@ def post(base, path, payload):
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"] = "ldap-auth-check-not-for-production"
env["AUTH_SECRET_KEY"] = SECRET
env["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(fake)
if required_group:
env["LDAP_REQUIRED_GROUP"] = required_group
else:
env.pop("LDAP_REQUIRED_GROUP", None)
# 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"],
@@ -214,6 +239,55 @@ def main():
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().
@@ -252,6 +326,84 @@ def main():
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: