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
65 changed files with 3024 additions and 2420 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 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. `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 ## Accessibility is in scope
Approved Aug 14, 2026 (C1). Any component you rebuild ships accessible or it is not done: 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: checks. In addition, for any task touching the frontend:
1. Run the app locally: `uvicorn server.app:app` against a throwaway SQLite database. 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 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. gloved-hands surface and is where the worst rendering was found.
3. Capture before and after screenshots into the PR. 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 If a done-when check cannot be verified, do not mark the task complete. Say which check
failed and why. 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. No prior context needed.
**Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**. **Repo:** `Project-SDE-WP-Suite` (primegit) — changes are merged to **`main`**.
**What changed:** the app now has a username/password login. Going live needs: > **This document replaced an earlier one.** Until Aug 24 2026 it described taking a
1. one new environment variable, > **username/password login portal** live: bcrypt hashes, an `AUTH_SECRET_KEY`, and a
2. a **rebuild** of the stack (not just a restart), and > first admin created with `manage_users create-admin <user> --password …`. All of
3. creating the first admin account. > 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 > **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 > bake the code in at build time (`COPY html/` and `COPY server/`). A plain restart
> Dockerfiles). A plain restart will **not** pick up the new code — the images must > will **not** pick up the new code — the images must be **rebuilt** from latest `main`.
> be **rebuilt** from the 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 | | Name | Value | Notes |
|------|-------|-------| |------|-------|-------|
| `AUTH_SECRET_KEY` | a long random string | **Required.** Signs the login session cookies. | | `AUTH_SECRET_KEY` | a long random string | **Required.** Unchanged — still signs the session cookies. Keep the existing value; changing it signs everyone out. |
| `AUTH_SESSION_HOURS` | `12` | *Optional.* Hours a login lasts before re-auth (defaults to 12). | | `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 **Do not point `LDAP_HOST` at a domain controller's name or at an IP address.** It is
openssl rand -base64 48 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 `AUTH_RESET_MINUTES` and `AUTH_RESET_COOLDOWN_SECONDS` can be deleted if present.
> per-process key — logins then reset on every restart and break across the 2 gunicorn They configured the password-reset email, which no longer exists.
> workers. It must be set to a fixed value.
The existing database variables (`POSTGRES_*`) are unchanged.
--- ---
## 2. Pull latest `main`, rebuild, and redeploy ## 2. Pull latest `main`, rebuild, and redeploy
- Pull the latest commit on `main` and redeploy the stack **with image rebuild enabled** - Pull the latest commit on `main` and redeploy **with image rebuild enabled**.
(e.g. "Re-pull and redeploy" / force rebuild). This rebuilds both the `webserver` and - The new Python dependency (`ldap3`) is in `requirements.txt` and installs during
`api` images. the rebuild.
- New Python dependencies (`bcrypt`, `PyJWT`) are in `requirements.txt` and install - A database migration drops the `users.password_hash` column. It runs automatically
automatically during the rebuild. at container start. **Every account, role and project membership is preserved**
- The `users` table is created automatically on API startup — **no DB migration needed.** 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**. **a. Did the API start at all?**
- 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:
```bash ```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 ```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`, Want `Verify return code: 0 (ok)`. If you get a connection error, the `api` container
`reset-password <user>`, `disable <user>`, `enable <user>`. 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**. Roles are stored locally and are not read from AD, so someone has to be made an admin
2. Sign in with the admin account from step 4. once. Sign in first — that creates your account — then:
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. ```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 ## Reference — what's in this release
- `server/auth.py`bcrypt password hashing, JWT session cookie, the request gate. - `server/ldap_auth.py`the LDAPS client: bind, nested-group check, certificate validation.
- `server/app.py``/api/auth/*` endpoints + middleware that refuses every `/api` data - `server/app.py``login()` binds instead of comparing a hash; password endpoints removed.
route without a valid session. - `server/auth.py` — sessions and roles only; no hashing, no reset tokens.
- `server/manage_users.py` — the CLI used in step 4. - `server/certs/prime-ca-chain.pem` — the CA bundle that validates the DC certificate.
- `html/login.html`, `html/auth-guard.js` — login page and per-page guard. - `server/manage_users.py``promote` / `demote`, each requiring a domain bind.
- `html/admin.html` / `admin.js`Admin Console gated on the admin role, with the user - `html/login.html`, `login.js`one view; "Forgot password?" points at Okta.
administration UI. - Migration `b7e4f1a20c93` — drops `users.password_hash`.
- 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).

View File

@@ -1,355 +0,0 @@
# Deploy runbook: WP Suite Okta cutover (wave 10)
**For:** IT / whoever administers the Docker host and Portainer
**From:** m.mabrey@prime-controls.com
**Revised:** 2026-09-03. First version of this runbook.
**Expected duration:** 20-30 minutes, including the backup and the live sign-in check
**Expected downtime:** under a minute, while containers are recreated
This is a separate runbook from `DEPLOY-runbook-2026-08-04.md`, not a revision of it.
That one still applies to its own deploy. Read this one in full before starting.
It has a step that the earlier one does not: this deploy removes the local password
system entirely, and one part of that removal cannot be undone by the usual means.
See "What this deploy changes" below.
---
## Fill these in before handing this over
| Thing | Value |
|---|---|
| Docker host (SSH target) | `________________` |
| Stack name in Portainer | `________________` |
| Site URL | `https://________________` |
| Stack directory on the host (holds `docker-compose.yml` / `backups/`) | `________________` |
| A real Okta account assigned to the app integration, for the live check in Step 4 | `________________` |
Container names are fixed by the compose file and are the same on every host:
`nginx_webserver`, `wp_api`, `wp_db`, `wp_db_backup`.
---
## What this deploy changes
Sign-in changes from a local username/password to Okta OIDC, completely. Not a
toggle, not a fallback. Three things make this more than a routine deploy:
1. **Five new environment variables are required**, and without them nobody can
sign in: `OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`,
`OKTA_REDIRECT_URI`, and optionally `OKTA_IDENTITY_CLAIM`. This is a genuine
change from how deploys usually go here. Do not skip Step 1.5.
2. **A migration drops the `password_hash` column** (`1d60a608bb51`), and it is
**one-way in practice.** Its `downgrade()` re-adds the column, but empty.
The real password hashes are gone the moment this commits, and no `alembic
downgrade` brings them back. If this deploy needs to be undone after that
point, going back to the old local-password code does not work on its own.
See the Rollback section, Case C. This is why Step 1's backup is not
optional the way it sometimes reads in other runbooks.
3. **There is no break-glass path**, by design (recorded decision D16). If Okta
is unreachable or misconfigured after this deploy, the app is unreachable for
everyone, admins included, until Okta is fixed. That is expected behavior,
not a bug to roll back from. See Rollback, Case A/B, before assuming
something is broken.
The visible change for people using the app: the login page becomes "Sign in
with Okta" instead of a username/password form. Nothing else in the app's
day-to-day behavior changes.
---
## Step 0: Record the current state (needed for rollback)
SSH to the Docker host and run:
```bash
docker exec wp_api alembic -c server/alembic.ini current
docker inspect nginx_webserver --format 'nginx image: {{.Image}}'
docker inspect wp_api --format 'api image: {{.Image}}'
```
**Copy the output into your ticket.** Also note the Git commit the Portainer
stack is currently on (Portainer, the stack, the Git reference / last-updated
commit). Without these, rollback is guesswork.
Expected output of the first command before this deploy: `a1b8c6d4e2f9 (head)`.
If it shows anything else, stop and check with me before continuing. This
runbook assumes that starting point.
---
## Step 1: Back up the database
On the Docker host:
```bash
docker exec wp_db_backup /scripts/db-backup.sh
```
Expected output ends with a line like:
```
[db-backup] wrote 1.4M /backups/wpsuite-20260903-141233Z.sql.gz.enc
```
Confirm the file is on the host (substitute the stack directory):
```bash
ls -lt <stack-dir>/backups | head -3
```
**Record that filename.** Do not continue until you have seen the `wrote ...`
line and the file in that listing.
This backup matters more than usual for this deploy. Once the migration in
Step 3 commits, this file becomes the *only* way to get local password hashes
back, for any reason. Treat it as the point you would restore to, not routine
housekeeping.
- A `.sql.gz.enc` extension means backups are encrypted. Expected and correct.
- A `.sql.gz` extension plus a `WARNING: BACKUP_ENC_PASSPHRASE not set` line
means backups are unencrypted. Not a blocker for this deploy; report it back.
- **No SSH access?** Portainer, **Containers**, `wp_db_backup`, **Console**,
connect with `/bin/sh`, then run `/scripts/db-backup.sh`. Same result: the
dump lands on the host, because `/backups` is a bind mount.
---
## Step 1.5: Confirm the Okta app integration is actually ready
Do this before redeploying, not after. Everything here is checked in Okta's own
admin console and in the values that will go into the stack's environment
variables. Nothing touches the WP Suite host yet.
1. The Okta app integration exists (Sign-in method: OIDC, Authorization Code,
Application type: Web Application), and the account listed in the fill-in
table above is assigned to it.
2. `OKTA_REDIRECT_URI` matches a "Sign-in redirect URI" registered on that app
integration **exactly**: scheme, host, and path, including whether it ends
in `/api/auth/okta/callback`.
3. `OKTA_ISSUER`, `OKTA_CLIENT_ID`, and `OKTA_CLIENT_SECRET` are the values from
that same app integration, not a different one.
4. If your Okta configuration puts the directory identity somewhere other than
the `preferred_username` claim, `OKTA_IDENTITY_CLAIM` is set to the right
claim name. If unsure, leave it unset; `preferred_username` is the default.
Add all five to the stack's **Environment variables** in Portainer now, before
Step 2. `OKTA_CLIENT_SECRET` should be handled the same way `AUTH_SECRET_KEY`
already is: not typed anywhere it will be logged.
If any of items 1-3 above are not yet confirmed, stop here and get them
confirmed first. A wrong redirect URI or an unassigned account will not corrupt
anything, but it does mean nobody signs in after this deploy until it is fixed.
See Rollback, Case B, which is the ordinary way that gets fixed and does not
involve the database at all.
---
## Step 2: Redeploy the stack in Portainer
1. Portainer, **Stacks**, select the stack.
2. **Pull and redeploy**, with re-pull / re-build **enabled**.
3. Wait for it to report success.
A plain "restart" or "stop/start" will not pick up new code, and will not pick
up the environment variables added in Step 1.5 either.
---
## Step 3: Confirm the containers came up
```bash
docker ps --filter name=nginx_webserver --filter name=wp_api --filter name=wp_db
```
All three must be `Up`, and `wp_db` should show `(healthy)`. Then check the API
applied its migration cleanly:
```bash
docker logs wp_api --tail 40
```
You are looking for an Alembic `Running upgrade a1b8c6d4e2f9 -> 1d60a608bb51`
line followed by gunicorn starting up, and no traceback. The API refuses to
start if a migration fails, so a restarting `wp_api` container means it failed.
Go to Rollback, Case B, and read the "did the migration commit" note there
before doing anything to the database.
Confirm the database landed on the new revision:
```bash
docker exec wp_api alembic -c server/alembic.ini current
```
Expected: `1d60a608bb51 (head)`. **Once you see this, you have passed the
point of no return described above.** The backup from Step 1 is now the only
way back to a working local-password system, if that is ever needed.
Then verify nginx's own view of its config:
```bash
docker exec nginx_webserver nginx -t
```
Expected: `syntax is ok` / `test is successful`.
---
## Step 4: Confirm Okta sign-in actually works, live
This is the step that matters most for this deploy. A clean container start
does not by itself prove sign-in works, and there is currently no startup log
line that confirms Okta config is good (logged separately as a follow-up, not
fixed as part of this runbook). The only real proof is a live sign-in.
1. Open the site's normal URL in a private/incognito window. It should land on
`login.html` with a "Sign in with Okta" button, not a username/password
form.
2. Click it. You should be redirected to your organization's actual Okta
sign-in page (the real Okta domain from `OKTA_ISSUER`, not this app's own
domain).
3. Sign in with the account from the fill-in table. You should land back on
the WP Suite site, signed in.
4. If this is the account's first-ever sign-in, it is now JIT-provisioned as a
regular user (`project_user`). To make it an admin, on the Docker host:
```bash
docker exec -it wp_api python -m server.manage_users promote <username> --role admin
```
This only works on an account that has already signed in once through Okta.
It promotes an existing row; it does not create one. That is deliberate
(recorded decision D16): there is no other admin-bootstrap path.
If step 2 or 3 fails (redirected to an Okta error page, redirected back to
`login.html` with an error, or nothing happens), this is almost always a
configuration problem from Step 1.5, not a code or database problem. Go to
Rollback, Case B, before considering anything more drastic.
Also confirm the API is reachable through the proxy and the redirect itself is
wired up:
```bash
curl -s https://<site-url>/api/health # -> {"ok": true}
curl -sI https://<site-url>/api/auth/okta/login | grep -i ^location # -> your Okta authorize URL
```
---
## Step 5: Hard-reload once in a browser, then sanity-check
Press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app uses a service
worker; a normal reload can serve the previous version.
1. Signed in as the account from Step 4, the home page offers to select or
create a project, same as before.
2. Open **Admin Console** as the promoted admin account. The user table shows
the account you just signed in with. There is no password column, no
"reset password" action anywhere in the UI.
3. Open a project and confirm a work package can be opened and edited
normally. Sign-in is the only thing this deploy changes, so the rest of
the app should look untouched.
**Deploy complete.** Please report back: the Step 0 output, the backup
filename from Step 1, and confirmation that Step 4's live sign-in worked.
---
## Rollback
Read this before assuming a rollback is needed. Cases A and B below do **not**
touch the database and are the far more likely outcome of something going
wrong here. Okta configuration is fiddly and easy to get slightly wrong. Case
C is the severe, destructive one, and should be a last resort, not a first
reaction.
### Case A: nginx won't start, or containers won't come up at all
Same as any other deploy: the database is untouched by container start-up
failures. In Portainer, redeploy the stack pinned to the **previous Git
commit** recorded in Step 0, then re-run Step 3.
```bash
docker logs nginx_webserver --tail 100
docker logs wp_api --tail 100
```
Send me whichever of those is relevant.
### Case B: containers are up, but Okta sign-in doesn't work
This is a configuration problem, not a data problem, and does **not** need a
code rollback or a database restore. Check, in order:
1. Is `wp_api`'s log showing anything at all when a sign-in is attempted?
`docker logs wp_api --tail 100`.
2. Do the five `OKTA_*` values in the stack's environment variables actually
match the Okta app integration (Step 1.5)? A copy-paste error in
`OKTA_CLIENT_SECRET` or a redirect URI that's off by a trailing slash are
the two most common causes.
3. Is the account assigned to the Okta app integration? An unassigned account
gets denied by Okta itself, before it ever reaches this app.
4. If a specific person can't sign in but others can, check
`OKTA_IDENTITY_CLAIM`. The claim it reads may not carry that person's
directory identity in the format expected. Confirm with security which
claim Okta is actually issuing.
Fix the environment variable(s) in Portainer, then redeploy (Pull and redeploy
is fine; the migration already applied and does not run again). No backup
restore, no code rollback.
If it's still not working after checking all four, send me the `wp_api` log
from item 1 along with which of items 2-4 you already ruled out.
### Case C: the decision is made to abandon Okta and restore local-password sign-in
This is the case the point-of-no-return warning in "What this deploy changes"
is about. Only reach for this if Case A and B do not apply. That is, Okta
itself is working correctly but a decision has been made to go back to the old
system entirely.
**This cannot be done with a code rollback alone.** The old code expects a
real `password_hash` on every user row. After Step 3 commits, that column is
either gone or (if `alembic downgrade` is run) present but empty. Either way,
nobody's stored password survived, including admins'. The only way to get a
working local-password system back is to restore the full database from the
Step 1 backup, which also rolls back every other change made since that
backup: new work packages, comments, uploaded files, everything.
**Do not do this without confirming with me first.** If it is confirmed, for
an encrypted dump, on the Docker host, in the `backups` directory:
```bash
export BACKUP_ENC_PASSPHRASE='<the passphrase, from the stack env vars>'
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE \
-in wpsuite-<timestamp>.sql.gz.enc \
| gunzip \
| docker exec -i wp_db psql -U wpsuite -d wpsuite
unset BACKUP_ENC_PASSPHRASE
```
For an unencrypted dump, drop the `openssl` stage and pipe `gunzip` straight
into `psql`. Substitute the real values if `POSTGRES_USER` / `POSTGRES_DB` are
not `wpsuite`. After restoring, redeploy pinned to the Git commit recorded in
Step 0, since the restored database matches the old schema, not this one.
Reach me at m.mabrey@prime-controls.com.
---
## Notes
- Do not run `docker compose down -v`. The `-v` flag deletes the `pgdata`
volume and with it the entire database.
- `docker exec <container-name>` is used throughout rather than
`docker compose ...`, because a Portainer-managed Git stack's compose project
lives under Portainer's own data directory and usually isn't reachable from
an ad hoc SSH session the same way.
- There is currently no startup log line confirming Okta config is valid
(`okta_auth.describe()` exists but nothing calls it yet, logged as a
follow-up). Step 4's live sign-in is the real verification until that's
wired in.
- Full background documentation: `DEPLOYMENT.md` and `server/README.md` in the
repository. `docs/waves/decisions-2026-09-03.md` (D16, D17) records why
there is no break-glass path and why this runbook exists as its own document.

View File

@@ -52,44 +52,34 @@ POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password> POSTGRES_PASSWORD=<strong-random-password>
# REQUIRED — signs login session cookies, AFTER Okta has confirmed who someone # REQUIRED — signs login session cookies. If unset, `docker compose up` errors
# is. If unset, `docker compose up` errors out and the API refuses to start. # out and the API refuses to start. Generate once and keep it stable:
# Generate once and keep it stable:
# openssl rand -base64 48 # openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret> AUTH_SECRET_KEY=<strong-random-secret>
# REQUIRED (in spirit — see the note below) — Okta OIDC is the only sign-in
# path (D15/D16). There is no local password anywhere in this app to fall back
# to, so without these nobody can sign in at all. Get them from the Okta app
# integration (sign-in method OIDC - Authorization Code, Web Application):
#
# OKTA_ISSUER the authorization server, e.g.
# https://your-org.okta.com/oauth2/default
# OKTA_CLIENT_ID \_ from the app integration
# OKTA_CLIENT_SECRET /
# OKTA_REDIRECT_URI must exactly match a "Sign-in redirect URI"
# registered on the app integration, e.g.
# https://wp-suite.company.local/api/auth/okta/callback
#
# Full explanation, and the optional OKTA_IDENTITY_CLAIM override, in
# server/.env.example. Not enforced at startup the way AUTH_SECRET_KEY is —
# the API starts without these, it just refuses every sign-in and says so in
# `docker compose logs api` (server/okta_auth.py's describe()).
OKTA_ISSUER=<https://your-org.okta.com/oauth2/default>
OKTA_CLIENT_ID=<from the Okta app integration>
OKTA_CLIENT_SECRET=<from the Okta app integration>
OKTA_REDIRECT_URI=<https://your-hostname/api/auth/okta/callback>
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds # Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
# customer IP. Keep the passphrase OFF this host — losing it makes dumps # customer IP. Keep the passphrase OFF this host — losing it makes dumps
# unrecoverable: openssl rand -base64 32 # unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase> BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email. Email is OFF by default and # OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email
# enabled from the Admin console; the host/port/from-address are configured # is OFF by default and enabled from the Admin console; the host/port/from-address
# there, but the password is only ever read from this variable (never stored # are configured there, but the password is only ever read from this variable
# in the DB or shown in the UI). Leave unset until you have SMTP details. # (never stored in the DB or shown in the UI). Leave unset until you have SMTP
# details.
# SMTP_PASSWORD=<smtp-app-password> # SMTP_PASSWORD=<smtp-app-password>
# 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_*` The API builds its own DB connection string from the `POSTGRES_*`
@@ -101,14 +91,34 @@ ignored whenever the three `POSTGRES_*` values are present.
Generate a strong password with `openssl rand -base64 32`. Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack these go in the stack's > **Portainer note:** for a Git-based stack the stack's **Environment variables**
> **Environment variables** section (Portainer doesn't read a local `.env`). > section is not merely an alternative to `.env` — it is the ONLY route, because
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` / > Portainer does not read a local `.env` at all. Every value the compose file
> `OKTA_ISSUER` / `OKTA_CLIENT_ID` / `OKTA_CLIENT_SECRET` / `OKTA_REDIRECT_URI` / > references as `${VAR}` has to be set there or it arrives empty.
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there. >
> 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 `POSTGRES_PASSWORD`, `AUTH_SECRET_KEY`, `BACKUP_ENC_PASSPHRASE`, `SMTP_PASSWORD` and
compose file or in git. 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 ## 3. Point your reverse proxy at the nginx container
@@ -169,34 +179,25 @@ project → SOP → Work Package → the AWP issue gate → status → metrics
archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq. archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq.
It **signs in first**, because every `/api/` route except `/api/health` requires a It **signs in first**, because every `/api/` route except `/api/health` requires a
session — but there is no local password to sign in with (D15/D16), and Okta session. Credentials come from the environment so a password stays out of shell
requires a real browser to complete, which this script cannot do. So instead history, and the account must be an **admin**: the run creates a project and deletes
of an HTTP login, it mints a session directly the same way `okta_callback()` it again, and archiving or deleting one takes Project Admin on it. The script checks
does after Okta hands back an identity, which means **it has to run somewhere the signed-in role up front and warns if it is too low rather than letting you find
that can read the same `AUTH_SECRET_KEY` and reach the same database as the out in the cleanup step.
server under test** — inside the `api` container, or local dev against your
own DB. It can no longer sign in to an arbitrary remote URL from an unrelated
workstation the way the old password-based version could.
The account named by `WP_SMOKE_USER` must **already exist** — sign it in
through Okta once first, or pre-create it from the User Directory — and must
be an **admin**: the run creates a project and deletes it again, and deleting
one takes Project Admin on it. The script checks the signed-in role up front
and warns if it is too low rather than letting you find out in the cleanup
step.
```bash ```bash
# From inside the api container — has AUTH_SECRET_KEY and DATABASE_URL, and export WP_SMOKE_USER=<admin-account>
# hits FastAPI directly. This is the normal way to run it in production: export WP_SMOKE_PASSWORD='…'
docker compose exec -e WP_SMOKE_USER api \
# Through the proxy (use --insecure for a self-signed internal cert):
python3 server/smoketest.py https://wp-suite.company.local --insecure
# Or from inside the api container (hits FastAPI directly). Pass the vars through:
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
python /app/server/smoketest.py http://localhost:8000 python /app/server/smoketest.py http://localhost:8000
# Local dev, against the app you're running yourself:
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=<admin-account>
python3 server/smoketest.py http://localhost:8000
# Add --keep to leave a demo project in the DB so you can open it in the UI. # Add --keep to leave a demo project in the DB so you can open it in the UI.
# --user overrides $WP_SMOKE_USER if you'd rather be explicit. # --user / --password override the environment if you'd rather be explicit.
``` ```
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
@@ -284,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) | | `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) | | `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` | | `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts | `username` (matched against Okta's identity claim — no password column; D15/D16), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), `token_version` | | `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 | | `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` | | `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) | | `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
@@ -302,9 +303,8 @@ Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`, `POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
`GET /api/wps/metrics` · `GET /api/wps/metrics` ·
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` · Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
Auth `GET /api/auth/okta/login` / `okta/callback` (the Okta sign-in round trip), Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management
`POST /api/auth/logout`, `GET /api/auth/me`, admin user management under under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
`/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
Admin-only `GET/PUT /api/settings`, Admin-only `GET/PUT /api/settings`,
`POST /api/settings/test-email`, `GET /api/notifications`, `POST /api/settings/test-email`, `GET /api/notifications`,
`GET /api/projects/{id}/members`. `GET /api/projects/{id}/members`.
@@ -324,6 +324,56 @@ docker compose up -d --build webserver # front-end change (html/) — rebuild
docker compose up -d --build api # backend change (server/) 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 ## Backups & retention
A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and A **`backup` sidecar** (in `docker-compose.yml`) runs `pg_dump` on a schedule and
@@ -378,31 +428,96 @@ TLS / From address and flips the master toggle.
package contents — so customer IP stays behind the login. package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling. - Use the card's **Send test email** button to confirm SMTP before enabling.
### Sign-in and admin bootstrap (Okta) ### Password reset — there isn't one
There is no local password anywhere in this app — no "Forgot password," no D13 removed local passwords entirely. **Turning email on no longer affects sign-in.**
reset link, nothing email-related to sign-in (D15/D16). `SMTP_PASSWORD` above The login page's "Forgot password?" links to `https://primecontrols.okta.com/`, which
is purely for WP-assignment notification emails. is the only self-service route; the app cannot reset a credential it does not hold.
Sign-in is entirely Okta's job: `login.html` redirects to Okta, and access Email still carries WP-assignment notifications and the critical-reopen mail.
control is **who is assigned to the app integration in Okta** — see step 2's
`OKTA_*` variables and [`server/README.md`](server/README.md#sign-in-okta) for ---
the full flow. The first admin has to sign in through Okta once (landing as an
ordinary `project_user`, auto-provisioned), then get promoted from a shell: ## 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 ```bash
docker compose exec api python -m server.manage_users promote alice --role admin 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}
``` ```
This is deliberate, not an oversight: a hand-typed username at account-creation Then prove the certificate path, without binding — this touches no account and so
time risks a second, orphaned row if it doesn't exactly match what Okta sends, cannot contribute to a lockout:
so the CLI promotes an existing Okta-provisioned row rather than creating one
blind (D16). Every admin after the first can be promoted from the User
Directory page — no shell access needed.
**No break-glass path.** If Okta is unreachable or misconfigured, the app is ```bash
unreachable for everyone, including admins, until Okta is restored — the same 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"
posture the abandoned LDAPS design took, carried forward deliberately (D16). # 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 ## Permissions roles
@@ -414,7 +529,7 @@ which no longer manages accounts.
| Role | May do | | Role | May do |
|---|---| |---|---|
| `admin` | User administration everywhere, app settings, and every project | | `admin` | User administration everywhere, app settings, and every project |
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: pre-create accounts by username, set permissions, grant project access | | `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project | | `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one | | `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
@@ -431,9 +546,9 @@ Its limits are what make it safe to hand out, and all of them are server-side
* **Scope comes from projects, not the job title.** A super user administers the users * **Scope comes from projects, not the job title.** A super user administers the users
of the projects they hold the role on — via their account role, or via of the projects they hold the role on — via their account role, or via
`ProjectMember.role` for a super user on one job only. No projects, no authority. `ProjectMember.role` for a super user on one job only. No projects, no authority.
* **Account changes need EXCLUSIVE scope.** Disabling, renaming, changing * **Account changes need EXCLUSIVE scope.** Resetting a password, disabling, renaming,
permissions or deleting are global acts, so they are refused when the target changing permissions or deleting are global acts, so they are refused when the
is also on a project the caller does not administer. The directory shows target is also on a project the caller does not administer. The directory shows
those rows read-only with the reason. An app admin has to make the change. those rows read-only with the reason. An app admin has to make the change.
* **No admin or super-user targets, and none granted.** A super user may hand out * **No admin or super-user targets, and none granted.** A super user may hand out
`project_admin` / `project_user` only, and may not touch an admin's or another `project_admin` / `project_user` only, and may not touch an admin's or another

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` | | 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` | | 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` | | 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 **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 roughly the first third of the effort. It is called out here because the Micron team is

View File

@@ -27,32 +27,26 @@ services:
POSTGRES_HOST: db POSTGRES_HOST: db
# Optional full-URL override (must be URL-encoded if used). # Optional full-URL override (must be URL-encoded if used).
DATABASE_URL: ${DATABASE_URL:-} DATABASE_URL: ${DATABASE_URL:-}
# Signs login session cookies, AFTER Okta has confirmed who someone is. # Signs login session cookies. REQUIRED — compose fails fast if it's unset,
# REQUIRED — compose fails fast if it's unset, and the API refuses to # and the API refuses to start in production without it (see server/auth.py).
# start in production without it (see server/auth.py).
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)} AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12} AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
# Okta OIDC — the only sign-in path (D15/D16). Not marked required the
# way AUTH_SECRET_KEY is: the API starts without these, it just refuses
# every sign-in and says so in the startup log (server/okta_auth.py
# describe()). See server/.env.example for what each one is and how to
# get it from the Okta app integration.
OKTA_ISSUER: ${OKTA_ISSUER:-}
OKTA_CLIENT_ID: ${OKTA_CLIENT_ID:-}
OKTA_CLIENT_SECRET: ${OKTA_CLIENT_SECRET:-}
OKTA_REDIRECT_URI: ${OKTA_REDIRECT_URI:-}
# NOT ${OKTA_IDENTITY_CLAIM:-} — server/okta_auth.py's own default only
# applies when the env var is UNSET, and compose setting it to an empty
# string here is not the same thing as leaving it unset. An empty value
# would make the API look for a claim literally named "", which is
# never present, so EVERY sign-in would 503. Mirror the same default
# here instead, so an operator who leaves .env's copy commented out gets
# the real default, not a broken one.
OKTA_IDENTITY_CLAIM: ${OKTA_IDENTITY_CLAIM:-preferred_username}
# Optional — SMTP password for WP-assignment emails. Email is off by # Optional — SMTP password for WP-assignment emails. Email is off by
# default and enabled from the Admin console; this is the only email # 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. # secret and it is never stored in the DB. Leave unset until configured.
SMTP_PASSWORD: ${SMTP_PASSWORD:-} 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, # Optional — read-only SQL Server connection to the Micron asset catalog,
# which backs the asset picker in the work package creator. Leave unset and # 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). # the picker cleanly falls back to manual entry (see server/assets_db.py).
@@ -64,10 +58,15 @@ services:
condition: service_healthy # waits for postgres to accept connections condition: service_healthy # waits for postgres to accept connections
networks: networks:
- internal - internal
# Reaching the Micron database means leaving this compose project, and # Reaching the Micron database — and, since D13, the domain controllers —
# `internal` is deliberately egress-free. `outbound` is attached to the api # means leaving this compose project, and `internal` is deliberately
# container ONLY — the database and backup containers stay sealed. Detach it # egress-free. `outbound` is attached to the api container ONLY; the database
# again if you are not using the Micron asset picker. # 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 - outbound
db: db:
@@ -131,7 +130,7 @@ networks:
# An ordinary bridge network, i.e. one that HAS a default gateway. `internal` # 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 # 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 # 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` # reach the domain controllers (LDAPS, D13) and the Micron asset database.
# remain on `internal` only and still have no way off the host. # Attached to `api` alone: `db` and `backup` remain on `internal` only and
# Detach it from api if you are not using the Micron asset picker. # still have no way off the host. Required — see the note on the api service.
driver: bridge driver: bridge

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 243 KiB

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -292,7 +292,7 @@ Wave 8 adds these:
```bash ```bash
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
python tests/materials_check.py # D6 - material list, the CR-005 pattern 20 checks python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks
python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks
``` ```

View File

@@ -555,110 +555,97 @@ deliberately deferred.
- **Suggested wave or follow-up:** next housekeeping pass, with the check - **Suggested wave or follow-up:** next housekeeping pass, with the check
widened so it cannot recur. 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) - **Found during:** `T10.3` (D13), stripping the password code paths
- **Where:** `Dockerfile` / build, `server/app.py` `/api/health`, admin console - **Where:** `server/notify.py`, `send_now()`
- **What:** the app carries no record of what code it is running. `/api/health` - **What:** `send_now` sends one message immediately, outside the outbox queue. Its
returns `{"ok": true}` and nothing identifies the deployed commit, so only caller was `forgot_password`, because a reset link must not sit in a queue.
answering "is the live site on the latest code?" took fingerprinting `T10.3` deleted that endpoint, so the function now has no callers anywhere in
(probing for files/routes that only exist after certain merges) in the `server/` or `tests/` — verified by grep, not assumed.
middle of an outage. The fix: bake the git SHA into the image at build time - **Resolution:** deleted. Raised as a judgement call between "remove it" and "keep
(`ARG GIT_SHA`), return it from `/api/health` it as the documented immediate-send path"; answered on Aug 21 — remove it. Nothing
(`{"ok": true, "version": "<sha>"}`), and show it on the admin console's in `server/` or `tests/` referenced it, and its docstring explained itself entirely
diagnostics card. Then currency is one glance against `git log -1`. in terms of password resets, which no longer exist. Keeping an unused sender that
- **Why not now:** new scope — needs its own item id per the working rules bypasses the outbox is a liability, not an asset: the next person to need immediate
(D13 is the natural next), and it touches the image build, which deserves a mail should write it against the requirement they actually have.
deploy alongside someone with host access. - **Note:** `send_email` (the raw SMTP call it wrapped) is untouched and still used by
- **Suggested wave or follow-up:** next housekeeping pass; ~1 task including a the outbox.
probe check that /api/health carries a version field.
### 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` - **Found during:** `T10.6` (D13), repointing "Forgot password?" at
migration crash-looped the api container) `https://primecontrols.okta.com/`
- **Where:** `DEPLOYMENT.md` (the update/deploy steps), `tests/` - **Where:** authentication as a whole — `server/ldap_auth.py`, `server/app.py` `login()`
- **What:** the migration chain is verified end-to-end on scratch SQLite, but - **What:** D13 chose an LDAPS simple bind, decided before it was known that the company
production runs Postgres, and the dialects disagree exactly where it hurts: runs an Okta tenant. Okta presumably federates to `prime.local` (which is why the
`server_default=sa.text('1')` on a Boolean passed every SQLite rehearsal and Windows password is still the one that binds), but its existence means an OIDC
was refused by Postgres at deploy (DatatypeMismatch), taking the API down authorization-code flow is available in principle. That would be strictly better on
until the table was created by hand. The hotfix (64eac0c) fixed that one three counts the LDAPS design cannot match: this app would never see a password at all,
instance and pinned the Boolean-default class in `materials_check`; the MFA would come for free, and the domain-lockout hazard that forced
CLASS of dialect drift is still unguarded. Two cheap layers: (1) a runbook `AUTH_MAX_ATTEMPTS` down to 2 would disappear entirely, because failed attempts would
step — render `alembic upgrade --sql` for the postgresql dialect and read it land on Okta rather than on a bind this endpoint makes.
before restarting (offline, needs no live DB; this render would have shown - **Why not now:** D13 was decided and reaffirmed, T10.1T10.4 are built and verified
`DEFAULT 1` on a boolean); (2) better, a probe that renders every migration against the live domain, and swapping the mechanism mid-wave is exactly the reordering
for the postgresql dialect on each run and fails on anything the dialect `CLAUDE.md` forbids. Recording it is not the same as reopening it.
rejects or on known-bad patterns. - **Suggested wave or follow-up:** its own item and its own decision, with Nick and
- **Why not now:** the outage is resolved and the one known instance is fixed whoever administers the Okta tenant. Not a widening of D13.
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).
### BL-028 — `okta_auth.describe()` is never called ### BL-028 — `assets_check` fails on any machine that has `MICRON_DB_URL` set
- **Found during:** T10.9 (writing the deploy runbook's live-verification step) - **Found during:** `T10.7` (D13), running the full suite
- **Where:** `server/okta_auth.py` (`describe()`), `server/app.py` (no caller anywhere) - **Where:** `tests/assets_check.py`, the "no `MICRON_DB_URL`" case
- **What:** `describe()` exists specifically to shout `"*** FAKE OKTA PROVIDER - **What:** the check asserts `/api/assets` answers `configured:false` when the catalog
ACTIVE..."` or report missing config at a glance, and both is not configured, but `start_server` passes the ambient environment through. On a
`server/.env.example` and `server/README.md` tell an operator to "check the developer machine whose `.env` sets `MICRON_DB_URL` — which is the normal state for
startup log line" for it. Nothing prints it. `app.py` never imports or calls anyone who has ever used the asset picker — the API is genuinely configured, returns
`describe()` at process start, so that log line does not exist and an operator real Micron tags, and three checks fail. Nothing is wrong with the app; the test's
following the docs will not find it. premise is violated by the environment it runs in.
- **Why not now:** a runbook is documentation, not server code; wiring a - **Fix:** SET `MICRON_DB_URL` empty for that server — do not pop it. `server/db.py`
startup log call is a real (if small) change to `app.py` and wants its own calls `load_dotenv()` at import, and python-dotenv only skips keys already present in
diff and its own verification, not a rider on T10.9. `os.environ`, so a *popped* variable is restored from the developer's `.env` inside the
- **Suggested wave or follow-up:** next housekeeping pass. One call subprocess and the test runs against the real catalog anyway. An empty string counts as
(`logger.info(okta_auth.describe())` near startup) plus updating present and therefore wins. `start_server` does exactly this for `LDAP_REQUIRED_GROUP`
`DEPLOY-runbook-2026-09-03.md` Step 4 to check the log line once it exists. (`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 — `users.failed_attempts` / `users.locked_until` are vestigial ### BL-029 — `generalinfo_check` flags a pre-existing `rgba()` in the creator stylesheet
- **Found during:** T10.6 - **Found during:** `T10.7` (D13), running the full suite
- **Where:** `server/models.py` (`User.failed_attempts`, `User.locked_until`) - **Where:** `html/wp-creation-styles.css:929` — `box-shadow:0 8px 24px rgba(20,30,50,.18)`
- **What:** both columns exist to support local-password lockout, which T10.4 - **What:** `generalinfo_check`'s token-rule check reports "no colour literal was added
removed. They are still reset to `0`/`None` on every Okta sign-in but nothing to the creator's stylesheet" and fails on `rgba(`. The literal predates this wave —
increments them anymore — dead columns, not a bug, but schema drift from the last touched by `8efe624` (F6) — and `git diff main...HEAD` shows the file untouched
D15 cutover. by the LDAPS branch.
- **Why not now:** T10.6 is documentation scope; dropping columns is a migration - **The real question is which is wrong.** `C4`'s recorded exception allows rgba
and belongs with the rest of the local-password cleanup, not folded into a **alphas** as opacity recipes, which is arguably what a shadow is; if so the check is
docs task. too strict and should match a colour literal rather than the `rgba(` token. If not,
- **Suggested wave or follow-up:** next housekeeping pass, alongside any other the shadow needs a token. Either way it is a one-line change plus a decision, and
post-cutover schema tidy-up. 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-031A raw `rgba()` shadow survives in the creator's stylesheet ### BL-030`token_version` is now near-vestigial; decide whether it earns its place
- **Found during:** T10.8 (the full suite could finally run through Docker; the - **Found during:** `T10.7` (D13), closing the done-when that assumed a role change bumps it
dev sandbox never had a headless browser to run it in before) - **Where:** `server/models.py` (`User.token_version`), `server/auth.py` (`create_token`,
- **Where:** `html/wp-creation-styles.css:929`, `.asset-results { ... box-shadow:0 `get_current_user`), `server/manage_users.py` (`_set_active`)
8px 24px rgba(20,30,50,.18); }` - **What:** `token_version` existed to invalidate live sessions when a password changed.
- **What:** `generalinfo_check.py` asserts the whole file carries no raw colour D13 removed passwords, and **nothing in `app.py` bumps it any more** — not
literal (comments excluded) as part of its CR-003 priority-colour check, and `set_user_role`, not `set_user_active`. Nor do they need to: `get_current_user` loads
this one line fails it: 48/49. Confirmed via `git show HEAD` that the literal the account from the database on every request, so a role change or a deactivation
is already committed, byte-identical, unrelated to wave 10 — it is the Micron takes effect on the next request regardless. `T10.3`'s note that "role changes and
asset picker's dropdown shadow, which arrived with the `origin/Micron-Assets` deactivation should bump it" describes an intention, not the code.
merge (D11, 2026-08-20) and was never swept by `C4`'s token pass (`T9.9` - **Its one remaining trigger** is the bump added to `manage_users._set_active` in
closed before D11 merged). The fix is a straight swap: `T10.9`, which is belt-and-braces rather than load-bearing — `is_active` alone already
`theme-light.css:217` already declares `--wp-shadow-menu: 0 8px 24px refuses the request.
rgba(20, 30, 50, .18)`, the identical value — this line should read - **The question:** is there still a case for invalidating a live cookie *without* also
`box-shadow:var(--wp-shadow-menu);`. disabling the account? If yes, wire it to something (a "sign out everywhere" control is
- **Why not now:** unrelated to the Okta wave; fixing a D11-era CSS literal the usual shape) and say so. If no, the column, the claim, and the check are three
inside T10.8 (auth verification) is exactly the drive-by `CLAUDE.md` forbids. places carrying a mechanism nothing triggers.
- **Suggested wave or follow-up:** next housekeeping pass, with `C4`'s other - **Why not now:** it is a design question about session handling, not an auth-wave bug,
leftovers. One-line fix, `generalinfo_check.py` already pins it (49/49 once and the mechanism works correctly — it is exercised in `ldap_auth_check`.
fixed). - **Suggested wave or follow-up:** next housekeeping pass.
### BL-030 — `DEPLOY-login-portal.md` is fully stale
- **Found during:** T10.9
- **Where:** `DEPLOY-login-portal.md` (repo root)
- **What:** the original username/password login rollout doc. References
`bcrypt`, `create-admin --password`, `AUTH_SECRET_KEY` as the only secret,
and a login form — none of which describe the app since T10.4/T10.5. Someone
handed this to IT today would be told to do things that no longer work.
- **Why not now:** no task currently owns deploy-doc cleanup as a category;
deleting or archiving a doc is a product/records call (`CLAUDE.md`'s "removed
fields are hidden, not deleted" spirit likely applies to docs too, but that is
worth confirming rather than assuming).
- **Suggested wave or follow-up:** next housekeeping pass — needs Nick on
whether to delete, archive, or rewrite it as historical record.

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.

View File

@@ -1,75 +0,0 @@
# Decisions — September 2, 2026
One item, and it retires two decided-and-built items rather than amending them.
---
## D15 — Authentication moves to Okta OIDC; D13 and D14 are retired before deployment
- **Amends:** retires `D13` (LDAPS simple bind against `prime.local`) and `D14` (the CLI
authenticates against the domain). Both were decided and reaffirmed August 21 2026,
built across nine tasks (`T10.1``T10.9`), and verified against the live domain. Neither
reached production. Approved by Nick Siegfried.
- **Surface:** `server/auth.py`, `server/app.py` (`login()`), `html/login.html`,
`html/login.js`, `html/auth-guard.js`. `server/ldap_auth.py` does not carry forward —
there is no LDAPS bind in the new design, not even as a fallback.
- **Wave:** 10. The label is reused fresh: the LDAPS work that previously answered to
"wave 10" was built on `feat/ldaps-directory-auth`, which is deleted rather than merged,
and never appeared in `IMPLEMENTATION.md`'s wave table. It carries no claim on the
number.
### What D13/D14 were, for the record
The branch carrying them is deleted, not merged, so their decision record
(`docs/waves/decisions-2026-08-21.md`) no longer exists on any branch. Preserved here so
the reasoning isn't lost along with it:
D13 chose a direct LDAPS simple bind to `ldaps://prime.local:636` as the sign-in
mechanism: no password stored, a successful bind was the authentication, accounts were
provisioned just-in-time from the directory, and roles stayed local. D14 moved the CLI
onto the same bind, removing `create-admin`/`create`. Both were built, tested
(1284/1288 checks, Aug 24), and screenshotted at 390px and 1440px. Neither ever deployed —
`wp.controls.dev` still runs the pre-D13 local-password login as of this decision.
### The decision
Skip LDAPS entirely. Authentication becomes an Okta OIDC authorization-code flow,
replacing local passwords directly — the same full replacement D13 intended, just via
Okta instead of a domain bind. No LDAPS bind exists in this design at any point.
Four things carry forward from D13 unchanged, because they were never LDAPS-specific to
begin with:
1. **No password is stored.** The app never sees a credential of any kind; Okta owns
authentication entirely.
2. **Accounts are provisioned just-in-time.** A first successful Okta sign-in with no
matching local `users` row creates one, at the default role. The matching logic that
was going to key off a directory search instead keys off an OIDC identity claim.
3. **Roles stay local.** Okta, and AD behind it, supplies identity only. This app decides
what an identity may do. Restated because it is the one rule the whole access-control
design depends on — see `BL-029` and the governance discussion that followed it.
4. **Existing accounts keep their roles** on first Okta login, exactly as D13's criterion
4 read for LDAPS.
### Why this, and not LDAPS first and Okta second
`BL-029` (recorded on this branch as `BL-027` before the renumbering forced by main's
independent use of that number) already laid out why OIDC beats the LDAPS bind on three
counts: this app never sees a password, MFA comes from Okta rather than needing to be
built, and the domain-lockout hazard that forced `AUTH_MAX_ATTEMPTS` down to 2 disappears,
because failed attempts land on Okta rather than on a bind this app makes. D13 was decided
before it was known the company already runs an Okta tenant. Once that was confirmed
(security's scoping reply, September 2026), shipping LDAPS first and replacing it with
Okta days later would mean building and deploying the weaker mechanism on purpose. Going
straight to Okta avoids that.
### What still needs answering before this is buildable
Open from the security scoping thread, not yet closed:
- Which OIDC claim carries the AD `sAMAccountName` equivalent (`preferred_username`,
`upn`, or a custom claim) — asked of security, answer pending.
- The exact redirect/callback URI once the hostname situation is reconfirmed
(`https://wp.controls.dev/api/auth/okta/callback` proposed).
- The `Business Technology Group` pilot in Okta, requested for initial testing, with
normal Okta session/MFA behavior rather than a stricter per-app rule.

View File

@@ -1,122 +0,0 @@
# Decisions — 2026-09-03
## D16: Okta admin bootstrap, break-glass posture, and the real scope of T10.4
Raised during hazard review for T10.4 (remove the local password path). D15 settled
*that* local passwords go away and Okta OIDC is the sole replacement; it did not settle
how an admin account gets named once there is no password to set, or what happens if
Okta itself is unreachable. Both are decided here.
### Admin bootstrap
`server/manage_users.py` stays the bootstrap tool — its own docstring already says so
("the `/api/auth/users` endpoint needs an existing admin, so you have to bootstrap one
here") — but it changes from *creating* an account to *promoting* one:
- An operator with shell/DB access on the server runs it against an account that
already signed in through Okta once and was JIT-provisioned by T10.3 (landing at
`project_user`, per that task). The command sets `role = admin` on that existing row
by username.
- It does **not** create a `User` row from scratch and does not touch `password_hash`
(the column is gone after T10.4's migration).
Rejected: minting a brand-new admin row by hand-typed username. `OKTA_IDENTITY_CLAIM`'s
exact format is still unconfirmed by security (open item carried from D15/wave-10.md).
A hand-typed username that doesn't exactly match what Okta actually sends produces a
second, orphaned account instead of promoting the real one. Promoting an
already-JIT-provisioned row sidesteps that entirely — it never has to guess the future
claim value.
Ongoing (non-bootstrap) admin naming needs no new work: `html/users.js` already has a
live role dropdown (`roleSelect`, gated by server-supplied `grantable_roles`) that lets
an existing admin promote any other account, including one JIT-provisioned via Okta.
That path is unrelated to the password removal and keeps working unchanged.
### Break glass
No break-glass path, by design. If Okta is unreachable or misconfigured, the app is
unreachable for everyone, including admins, until Okta is restored.
This matches the precedent already on record for the abandoned LDAPS design (D13/D14):
"LDAPS is the *only* path, no local fallback, no break-glass." Carried forward
deliberately rather than assumed to still apply, given Okta's failure modes differ from
an internal LDAP bind — considered and confirmed, not defaulted into.
Rejected: a toggleable emergency local login gated behind an env flag. It would
reintroduce a stored local credential, exactly what D15 exists to eliminate, for a
scenario (Okta down) judged less likely and less costly than the standing risk of a
forgotten emergency backdoor.
The server-shell CLI (`manage_users.py`, promoting an existing row) is not a formal
break-glass mechanism — it cannot help if no account has ever signed in through Okta —
but it is the same trust tier as "someone with SSH/container access to prod could
already edit the database directly," and it costs no new engineering.
### T10.4 scope correction
Hazard review found real call sites of `hash_password` / `verify_password` /
`password_problem` that the original T10.4 bullet in `wave-10.md` didn't name and that
break the moment those functions are deleted:
- `create_user()` and `admin_reset_password()` in `server/app.py` (the admin console's
"add user" and "reset password" routes).
- `html/users.js`'s "add user" form (`nu-password` field) and "Reset password" button.
- `server/manage_users.py`'s `create`, `create-admin`, and `reset-password` subcommands
(see bootstrap section above for its replacement).
- `tests/browser_check.py` and `tests/launcher_check.py`, which call
`auth.hash_password()` to seed fixture rows.
Also found: `server/smoketest.py` and `server/seed_demo.py` authenticate via
`POST /api/auth/login`, which T10.4 removes, and CLAUDE.md's own verification section
names both scripts as required checks. Fix folded into T10.4 rather than deferred to
T10.7: both scripts switch to minting a session with `auth.create_token()` and setting
the cookie directly, the same technique `tests/browser_check.py` already uses instead
of scripting a login form. No live Okta tenant needed, and T10.4 no longer depends on
T10.7's timing.
`wave-10.md`'s T10.4 bullet is updated to reflect this full scope.
## D17: A dedicated rollback-aware runbook for the Okta cutover deploy
Raised while preparing to close out wave 10: T10.4's migration
(`server/alembic/versions/1d60a608bb51_drop_local_password.py`) drops the
`password_hash` column, and its `downgrade()` re-adds the column with
`server_default=''`. That restores the schema, not the data — the real bcrypt hashes
are destroyed the moment `upgrade()`'s `op.drop_column` commits, and no amount of
`alembic downgrade` brings them back. `DEPLOY-runbook-2026-08-04.md`, the existing
precedent for how a deploy of this repo is handed to IT, has no equivalent case in its
own Rollback section — its migrations are additive or reversible, so nothing there
warns an operator that this one is different.
Three options were on the table: write this runbook now; also document a staged
deploy sequence (ship T10.5's Okta-live-alongside-password state first, verify real
sign-ins, then ship T10.4's column drop as a separate follow-up deploy); or log the
hazard to `backlog.md` and stop there. Decided: write the runbook only. Staged
sequencing is real risk reduction but is a second deploy plan on top of a wave that is
otherwise a single cutover (D15's "full replacement," not a toggle) — worth
proposing on its own if IT wants it, not worth building unasked. Backlog-only was
rejected because the hazard is concrete and dated (this wave, this migration), not a
someday item.
### What the runbook has to do differently from the 2026-08-04 precedent
- Name the five `OKTA_*` environment variables as newly required for this deploy —
the precedent's own "no new environment variables" note does not apply here and
restating it unchanged would be actively wrong.
- Treat the pre-deploy backup (`docker exec wp_db_backup /scripts/db-backup.sh`) as
the only way back once `1d60a608bb51` commits, not as routine due diligence.
- Separate two failure modes that look similar but are not: an Okta app integration
that is misconfigured after a clean deploy (redirect URI, client secret, an
unassigned test account) is fixable in place — fix the config, redeploy the `api`
service, no data at risk, migration already applied and stays applied. Deciding to
abandon Okta and restore local-password code is the severe case — the empty
`password_hash` column means the old code has nothing to check a password against,
so the only way back is the destructive backup restore (`DEPLOY-runbook-2026-08-04.md`
Case C's own procedure, reused here). Conflating these two would send an operator
straight to a destructive restore for a problem that a config fix would have solved.
- Fold in D16's no-break-glass posture: if Okta is down or misconfigured, the app is
down for everyone, including admins, by design — not a defect to roll back from.
Filed as **T10.9** in `wave-10.md` rather than folded into T10.6 (already merged and
verified) or T10.8 (UI/test verification, a different kind of check). New scope found
after the task that raised it closed gets a new ID, per `CLAUDE.md`.

View File

@@ -1,233 +1,399 @@
# Wave 10 — Okta OIDC authentication # Wave 10 — Domain authentication over LDAPS
Fresh wave 10. The label was previously used by the LDAPS work under `D13`/`D14`, built on **Items:** `D13`, `D14`
`feat/ldaps-directory-auth`; that branch was deleted rather than merged and never appeared **Depends on:** wave 9 merged (it is — `a8e28bf`)
in `IMPLEMENTATION.md`'s wave table, so it carries no claim on the number. See
`docs/waves/decisions-2026-09-02.md` (`D15`) for why LDAPS was retired before deployment
and Okta chosen instead.
Depends only on `main` as it stands after `D15`. Not sequenced behind any other wave. 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.
## Tasks **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.
- **T10.1 — Add the Okta OIDC client.** Authlib as a dependency. Config via env vars > **BEFORE PRODUCTION: `LDAP_REQUIRED_GROUP` must be set.** It is empty by default,
(`OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_REDIRECT_URI`), same > and empty means *no group gate* — every account in `prime.local` may sign in. The
pattern `AUTH_SECRET_KEY` already uses in `server/auth.py`. > 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)`.
- **T10.2 — Login-redirect and callback routes.** A route that sends the browser to **Build order is task order** for once. `T10.1` is a standalone module with no callers and
Okta's authorize endpoint, and a callback route that exchanges the code for tokens and should merge first; nothing else can be tested until it exists.
validates the ID token. Access gating is Okta's job, not this app's: only accounts
assigned to the app integration in Okta can reach it at all, so there is no app-side
required-group or claim check layered on top. This is a deliberate difference from D13,
which had to gate on a required AD group itself because an LDAPS bind alone could not
distinguish an assigned user from any other domain account.
- **T10.3 — Identity matching and JIT provisioning.** Reuses D13's shape ---
(`_provision_from_directory`-style matching) keyed off an OIDC claim instead of an LDAP
search result. **Open dependency:** which claim carries the AD `sAMAccountName`
equivalent (`preferred_username`, `upn`, or a custom claim) is asked of security and not
yet answered. Build with a configurable claim name and a documented default, not a
hardcoded one, so the answer can drop in without a code change.
- **T10.4Remove the local password path entirely.** Drop `password_hash` (Alembic ### T10.1The LDAPS client
migration; plain `op.drop_column`, matching existing precedent for other NOT NULL
columns on `users` — no `batch_alter_table` needed), remove the bcrypt-based
`login()`, remove the username/password form. Real deletion, matching `D15`'s "full
replacement," not a toggle or a fallback. Scope corrected by `D16` after hazard
review turned up more call sites than the original bullet named:
- `create_user()` and `admin_reset_password()` in `server/app.py` (admin console's
"add user" and "reset password" routes) — rework to drop the password field
entirely rather than break.
- `html/users.js`'s "add user" form (`nu-password`) and "Reset password" button —
matching frontend change.
- `server/manage_users.py` — reworked per `D16` from account *creation* to
*promotion*: `create-admin`/`create`/`reset-password` (password-based) are
replaced by a promote-by-username command that operates on a row Okta's JIT
provisioning (T10.3) already created, never a hand-typed new one. This is now
the documented admin-bootstrap path — see `D16`.
- `tests/browser_check.py` and `tests/launcher_check.py` — stop calling
`auth.hash_password()` to seed fixture rows.
- `server/smoketest.py` and `server/seed_demo.py` — currently authenticate via
`POST /api/auth/login`. Switch to minting a session with `auth.create_token()`
directly, the same technique `browser_check.py` already uses, so both scripts
(named explicitly in `CLAUDE.md`'s verification section) keep working without
depending on `T10.7`'s timing.
- **T10.5 — Frontend: login becomes a redirect, not a form.** `login.html`/`login.js` - **Items:** `D13` (1)
change to a "Sign in with Okta" flow. Sign-out lands back on the app's own login page. - **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`
- **T10.6 — Deployment docs and env var reference.** `DEPLOYMENT.md`, **Problem:** There is no directory client in the repo. `ldap3` is not a dependency, the API
`server/.env.example`, `server/README.md` describe the Okta config in place of the LDAP container has no CA bundle, and the `api` service sits on the `internal` network, which has
config they never ended up describing (D13 never shipped, so these still describe the no default gateway and therefore no route to `192.168.3.x` at all.
original local-password system today).
Built: all three rewritten — the five `OKTA_*` vars documented the same way **Do:** Add `ldap3` (pinned, per the convention at the top of `requirements.txt`). Write
`AUTH_SECRET_KEY` already was, the login-portal/self-service-reset sections replaced `server/ldap_auth.py` exposing two functions and nothing else:
with the Okta flow and the promote-not-create admin bootstrap (D16), the smoke-test
walkthrough updated for `WP_SMOKE_USER`-only / must-share-`AUTH_SECRET_KEY`-and-DB
(T10.4's `smoketest.py` rewrite). Also `docker-compose.yml`, not originally named in
this bullet: its `api` service sets `environment:` as an explicit allowlist, not
`env_file`, so the documented vars would silently never reach the container without
adding them there too — found and fixed in the same commit rather than shipping docs
for a config path that doesn't work. `OKTA_IDENTITY_CLAIM` mirrors
`okta_auth.py`'s own default (`preferred_username`) in the compose file rather than
defaulting to an empty string, which would 503 every sign-in.
Logged, not fixed here (out of scope): `users.failed_attempts`/`locked_until` are - `verify(username, password) -> LdapResult | None` — simple bind as
vestigial (still reset on every Okta sign-in, nothing increments them since local `f"{username}@{domain}"` against `ldaps://{host}:636`.
`login()` is gone); `server/README.md`'s own "Production — Docker Compose" section - `member_of(conn, group) -> bool` — nested-group aware, via
is a self-contained alternate quickstart that already diverged from the real root `LDAP_MATCHING_RULE_IN_CHAIN` (`1.2.840.113556.1.4.1941`). `memberOf` alone is direct
`docker-compose.yml` before this task and still does. membership only and will wrongly refuse anyone in a nested group.
- **T10.7 — Test coverage without a live Okta dependency.** A fake-OIDC-provider test Configuration by environment: `LDAP_HOST` (default `prime.local`), `LDAP_DOMAIN` (default
seam, mirroring `ldap_fake.py`, so the suite runs with no live Okta tenant reachable. `prime.local`), `LDAP_CA_FILE`, `LDAP_REQUIRED_GROUP`, `LDAP_TIMEOUT_SECONDS`. Attach the
Built: `server/okta_fake.py` (env-driven, `WP_OKTA_FAKE_DIRECTORY`, production-refusing `outbound` network to `api` in `docker-compose.yml` — the same reason `assets_db.py` needed
the same way `ldap_fake.py` does), dispatched from `okta_auth._build_oauth()` before it, and the comment there already explains the gateway-less `internal` network. Mount or
the real Authlib client is considered. Only the two Authlib calls that touch the `COPY` the PEM bundle and point `LDAP_CA_FILE` at it.
network — `authorize_redirect` / `authorize_access_token` — are faked; `app.py`'s
`okta_login()`/`okta_callback()` (the `?next=` guard, the disabled-account check, JIT
provisioning, the identity-claim lookup) run unmodified against the fake, same
boundary the LDAP predecessor drew around the anonymous-bind guard. Two fake-only
routes (`/_fake_provider`, `/_fake_provider/consent`) stand in for Okta's own sign-in
screen and are registered in `app.py` only when the fake is active at import time —
in production they do not exist, not merely refuse. `tests/browser_check.py`'s
`start_server()` now takes an optional `extra_env` and sets
`WP_OKTA_FAKE_DIRECTORY` unconditionally (same reasoning `ldap_fake`'s equivalent
used: almost nothing signs in, but the one check that does should not fail
mysteriously). `tests/url_state_check.py` scenario 2 is un-skipped and drives the
real round trip — login.html's button, the fake picker page, the fake consent
redirect, `okta_callback()` — proving `?next=` survives it, same tightened
"actually left login.html" assertion the LDAP predecessor's own bug fix used.
`tests/okta_auth_check.py` is new: the production guard, single-use/replay on the
authorization code, an unsolicited callback hit, a tampered state, a denied consent,
an unknown identity, a disabled account, JIT provisioning, an existing admin
surviving unchanged, same-site vs. off-site `?next=`, and `OKTA_IDENTITY_CLAIM`
genuinely working under a non-default claim name — 22/22.
The dev sandbox this was built in has no headless browser and no way to install Connect to **`prime.local`**, never a DC hostname and never an IP. See the decision doc for
one, so `tests/url_state_check.py` and `tests/browser_check.py` (both need why: SAN coverage plus round-robin across six DCs in one move. Wrap the bind in a retry
`tests/cdp.py`'s real headless Chromium) could not be run there — only across resolved addresses, because round-robin will hand out a rebooting DC's address.
`okta_auth_check.py`'s HTTP-level coverage of the same mechanism. Run for real
afterward on a machine with Docker, via a separate general-purpose tool
(`headless-py-test-runner`, kept out of this repo — it is not Work Package Suite
specific): `url_state_check.py` 26/26, including scenario 2's real click-through of
the fake-Okta round trip, and `browser_check.py` 71/71. Gap closed.
**Validated 2026-09-03:** `tests/okta_auth_check.py` 22/22 (no browser needed) · **Done when:**
`tests/url_state_check.py` 26/26 · `tests/browser_check.py` 71/71 — all three run
clean, the last two against real headless Chromium via Docker.
- **T10.8 — Verification.** 390px and 1440px, full suite, done-when checks per task, - [x] `ldap3` is pinned to an exact version in `requirements.txt`
matching the rigor D13 was held to. - [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`)
Done-when checks per task, verified against the actual code rather than ---
re-reading this file's own claims: T10.1 (Authlib pinned, the four env vars,
`is_configured()`/`describe()`), T10.2 (the login/callback routes, no
app-side group or claim gate layered on Okta's own), T10.3 (identity-claim
matching, JIT at the lowest role, local deprovisioning still enforced after
Okta approves), T10.4 (zero remaining references to `bcrypt` /
`password_hash` / `hash_password` / `verify_password` anywhere in `.py` or
`.js`, `manage_users.py promote`, `smoketest.py` / `seed_demo.py` /
`browser_check.py` / `launcher_check.py` all minting via `create_token()`),
T10.5 (`login.html` is one Okta link, no password field). All matched what
this file already claimed — no drift found.
Full suite, run through the Docker test runner (all 41 files in `tests/`, ### T10.2 — The login path binds instead of hashing
bare invocation): 39 passed clean. `tests/token_check.py` "failed" at exit 2,
but that is a harness mismatch, not a check failure — it is a two-step
snapshot/diff tool (`--out` to capture, `--compare A B` to diff) and prints
usage + exits 2 when run with no arguments, which is what a bare full-suite
pass does to every file. `tests/generalinfo_check.py` scored 48/49 — the one
failure is a raw `rgba()` shadow literal in `wp-creation-styles.css`,
confirmed via `git show HEAD` to already be committed and unrelated to this
wave (it is the Micron asset picker's dropdown shadow from the `D11` merge,
2026-08-20, predating this wave by two weeks). Logged as `BL-031` rather than
fixed here — an unrelated CSS token-rule violation is not this wave's to fix.
`tests/okta_auth_check.py` re-run fresh (no browser needed): 22/22.
390px and 1440px: `tests/baseline_shots.py` captured all fourteen shots - **Items:** `D13` (1)
(login, launcher, sop, creator, admin, users, field × two widths) into - **Depends on:** T10.1
`docs/reference/baseline/`. `login-390.png`/`login-1440.png` and - **Blocks:** T10.3, T10.4
`users-390.png`/`users-1440.png` visually confirmed: the login page is a - **Surface:** `server/`
single "Sign in with Okta" button with no username/password form at either - **Files:** `server/app.py` (`login`), `server/auth.py`
width, and the User Directory's table and "Add a user" form both carry no
password column and no reset-password action anywhere, at either width.
Wave 10 is complete. The three items in "Still open" below are external **Problem:** `login()` at `server/app.py:681` calls `auth.verify_password` against
(security team / Okta admin), not blocked on any task in this wave. `user.password_hash`. The lockout counter it maintains is about to start counting *domain*
bind failures, which changes what that counter is for.
- **T10.10 — Audit `manage_users.py promote`.** Raised in review after T10.8: **Do:** Replace the credential check with `ldap_auth.verify`. Keep the surrounding shape —
`cmd_promote()` changed a user's role with no audit trail at all, unlike the the deliberate timing equalisation, the generic `401`, the `403` for a disabled local
identical role change from the web Admin Console (`app.py`'s account, `last_login_at`, the session cookie. Rework the throttle so the local counter trips
`set_user_role()``log_event()`, action `"role_changed"`). Not a new **before** the domain policy is reached and short-circuits without calling the DC: the
privilege — anyone with Portainer/container-exec access to `wp_api` already failure mode to avoid is this endpoint being usable to lock domain accounts out of Windows.
has shell access to the database directly, same trust tier D16 already named Log directory error-49 sub-codes for diagnosis; return the same generic message regardless.
for this command — but there was no record of who ran it or what changed.
Built: `cmd_promote()` now writes an `AuditLog` row with the same **Break-glass: none — decided Aug 21, see the decision doc.** LDAPS is the only way in, so
`action`/`detail` shape `set_user_role()` uses (`{"from": old_role, "to": this task adds no fallback path. What it must add instead is *visibility*: a startup log line
role}`), tagged `"via": "cli"` (mirrors JIT provisioning's own `"via": stating whether LDAP is configured and whether the DC answered, because with no fallback a
"okta_jit"` tag) and `actor="cli:manage_users"` — a container shell exec misconfigured deploy is indistinguishable from a forgotten password at the login box.
carries no signed-in identity to attribute the change to a real person, so
it names the tool rather than guessing one. Verified end to end against a
scratch SQLite database: the audit row lands with the exact expected shape,
the role change persists, and the existing "no such user" refusal still
exits 1 with no partial write.
- **T10.9 — Rollback-aware deploy runbook.** Raised after hazard review found **Done when:**
`DEPLOY-runbook-2026-08-04.md`'s Rollback section has no case for a migration whose
`downgrade()` cannot restore the data it drops — see `D17`. `T10.4`'s
`1d60a608bb51_drop_local_password` is exactly that: the schema comes back, the
bcrypt hashes do not.
Built: `DEPLOY-runbook-2026-09-03.md`, following the 2026-08-04 runbook's structure - [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.
(fill-in table, numbered deploy steps, case-by-case Rollback section, Notes). Names - [x] a correct domain password signs in and sets the session cookie — confirmed against the live domain Aug 24, and in `ldap_auth_check`
the five `OKTA_*` vars as newly required (the precedent's "no new environment - [x] a wrong password is refused with the generic message
variables" note does not carry over), treats the pre-deploy backup as the only way - [x] a blank password is refused (guards `T10.1` from the caller's side too)
back once the migration commits, and splits Rollback into the fixable case (Okta app - [x] a user not in the required group is refused even though the bind succeeded
integration misconfigured — fix and redeploy `api`, no data at risk, migration stays - [x] `is_active = false` locally still refuses, independent of the directory — a disabled account is refused 403 even though the bind succeeds
applied) versus the severe case (abandoning Okta for local-password code — only the - [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
destructive backup restore gets there, reusing the 2026-08-04 runbook's own Case C - [x] no response body distinguishes "no such user" from "wrong password"
procedure). D16's no-break-glass posture is stated plainly rather than left implicit. - [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
Logged, not fixed here (out of scope): `okta_auth.describe()`'s startup log line ---
(referenced by `DEPLOYMENT.md`/`server/README.md`) has no caller anywhere in
`server/app.py` — nothing actually prints it at process start. The runbook's Step 4
therefore verifies via a live Okta sign-in rather than a log line, and this gap is
flagged in `docs/waves/backlog.md` as a candidate fix (wiring `describe()` into
startup) since it directly bears on deploy verifiability. `DEPLOY-login-portal.md`
is now fully stale (bcrypt, `create-admin --password`, none of which still exist) —
not touched, no task claims it.
## Still open ### T10.3 — Drop `password_hash`
- The `Business Technology Group` pilot assignment in Okta. Originally six names - **Items:** `D13` (1)
(Carlee Swihart, Drew Hilliard, Matt Mabrey, Nick Siegfried, Rachel Schreiber, Terry - **Depends on:** T10.2
Sajan); Cody and Cameron added 2026-09-09. Adrian added only Matt at first, - **Blocks:** T10.6, T10.8
deliberately, pending the live sign-in confirmation below — awaiting his response to - **Surface:** `server/`
add the rest of the group now that it has. - **Files:** `server/models.py`, new migration, `server/app.py`, `server/auth.py`,
`server/manage_users.py`
Closed since first written: admin bootstrap and break-glass posture, previously open **Problem:** With binds doing the work, every password code path is dead weight and a
questions, decided in `D16` (2026-09-03) and folded into `T10.4` above. liability. It is also the criterion that makes this item irreversible, so it lands on its own
commit.
**Closed 2026-09-09, live in production:** the redirect/callback URI **Do:** Remove `password_hash` from `models.User` and drop the column in a new Alembic
(`https://wp.controls.dev/api/auth/okta/callback`) is confirmed working, and so is the revision with `down_revision = 'a1b8c6d4e2f9'` (the current head — confirm with
OIDC claim mapping (`T10.3`) — `preferred_username` (the code's documented default, `alembic heads` rather than trusting this line). Remove from `auth.py`: `hash_password`,
never actually confirmed by name in Request 50649's thread) is correct, no `verify_password`, `password_problem`, `MIN_PASSWORD_LEN`, `_COMMON_PASSWORDS`,
`OKTA_IDENTITY_CLAIM` override needed. Both settled by an actual live sign-in against `create_reset_token`, `decode_reset_token`, `RESET_MINUTES`. Remove from `app.py`:
the real Okta tenant after `main` was merged (`cc64c88`) and deployed: Matt signed in `/api/auth/forgot-password`, `/api/auth/reset-password`, `/api/auth/reset-available`,
as himself, matched his existing pre-Okta admin account by `find_user()` rather than `/api/auth/password`, `/api/auth/users/{user_id}/password`, and the `_reset_last` throttle
JIT-provisioning a duplicate (the account already existed — this app has ~40 real with `reset_body`. Remove `reset-password` from `manage_users.py` and the password prompt
users, not the seeded test fixture), landed on `index.html` signed in, admin role and from `create` / `create-admin`.
project access untouched. One real deploy-time snag on the way, worth recording since
it's exactly the Case B scenario `DEPLOY-runbook-2026-09-03.md` anticipated: the first `token_version` **stays.** It is still the session-revocation mechanism — role changes and
redeploy left `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET`/`OKTA_ISSUER` as empty rows in deactivation should bump it even though password changes no longer exist.
Portainer (env var names added, values never filled in) — caught via
`is_configured()`'s all-four-required check failing closed (the 503 "Sign-in is **Do not** remove the account-management endpoints themselves. `/api/auth/users/{id}/role`
temporarily unavailable"), not silently. A second snag after filling those in: is criterion 4 and must keep working.
`OKTA_ISSUER` was pasted without its `https://` scheme, which surfaced as
`httpx.UnsupportedProtocol` from Authlib's OIDC discovery fetch rather than anything **Done when:**
the app's own code raises deliberately — the exact case the runbook's Notes flagged as
having no startup-time confirmation (`okta_auth.describe()` still has no caller, - [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
`BL-028`). Both fixed by correcting the env var values in Portainer and redeploying; - [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`)
neither needed the backup or the database. - [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,11 +60,6 @@
.then(function () { window.location.replace('login.html'); }); .then(function () { window.location.replace('login.html'); });
}; };
// window.wpChangePassword used to open a change-password dialog here. Removed in
// T10.4 (D15/D16): there is no local password to change anymore — identity is
// Okta's job. The "Password" item that called this is gone from wp-sidenav.js
// too.
// ── permissions helpers ──────────────────────────────────────────────────── // ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in // 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. // user can't use, so nobody clicks a button just to get a 403.
@@ -119,8 +114,7 @@
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable // Admin, Users and Sign out from the navigation drawer, and being one unbreakable
// 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px // 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px
// and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including // and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including
// the item that was only here: Language & time. (Password was the other one; T10.4 // the two items that were only here: Language & time, and Password.
// removed it along with the rest of local auth — D15/D16.)
// //
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page // Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
// left that would need a floating fallback pill. // left that would need a floating fallback pill.

View File

@@ -31,23 +31,34 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.brand img { height: 36px; width: auto; } .brand img { height: 36px; width: auto; }
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; } h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; } .sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
.btn { label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; }
display: block; .field { margin-bottom: 1.25rem; }
input[type=text], input[type=password] {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
background: var(--cds-field);
border: none;
border-bottom: 1px solid var(--cds-border-strong);
outline: 2px solid transparent;
outline-offset: -2px;
}
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
button {
width: 100%; width: 100%;
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
text-align: center;
text-decoration: none;
color: var(--cds-text-on-color); color: var(--cds-text-on-color);
background: var(--cds-button-primary); background: var(--cds-button-primary);
border: none; border: none;
transition: background 0.15s; transition: background 0.15s;
} }
.btn:hover { background: var(--cds-hover-primary); } button:hover:not(:disabled) { background: var(--cds-hover-primary); }
.btn:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: 2px; } button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; }
.error { .error {
display: none; display: none;
background: var(--wp-status-error-bg); background: var(--wp-status-error-bg);
@@ -58,6 +69,7 @@
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.error.show { display: block; } .error.show { display: block; }
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
.ok { .ok {
display: none; display: none;
background: var(--wp-status-success-bg); background: var(--wp-status-success-bg);
@@ -68,7 +80,15 @@
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.ok.show { display: block; } .ok.show { display: block; }
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; } .note {
font-size: 0.8125rem; color: var(--cds-text-secondary);
background: var(--cds-layer-accent); border-left: 3px solid var(--cds-link-primary);
padding: 0.75rem; margin-bottom: 1.25rem;
}
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
a.link:hover { text-decoration: underline; }
.center { text-align: center; margin-top: 1.25rem; }
</style> </style>
</head> </head>
<body> <body>
@@ -79,10 +99,30 @@
<div id="error" class="error" role="alert"></div> <div id="error" class="error" role="alert"></div>
<div id="ok" class="ok" role="status"></div> <div id="ok" class="ok" role="status"></div>
<h1>Sign in</h1> <!-- SIGN IN -->
<p class="sub">Work Package Suite uses your organization's Okta sign-in. Select the <section id="view-login">
button below and follow the prompts there.</p> <h1>Sign in</h1>
<a id="okta-signin" class="btn" href="/api/auth/okta/login" autofocus>Sign in with Okta</a> <p class="sub">Work Package Suite</p>
<form id="login-form" autocomplete="on">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
</div>
<div class="field">
<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>
<!-- 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> <p class="foot">Authorized use only · BTG / Pilot</p>
</main> </main>

View File

@@ -1,15 +1,19 @@
/* Login page logic for the Work Package Suite. /* Login page logic for the Work Package Suite.
One action: sign in with Okta. There is no local password anymore (D15/D16, One view. Sign in posts to /api/auth/login, the server authenticates by binding
T10.4) — this page's only job is building the link to /api/auth/okta/login to the domain over LDAPS (D13), and on success sets an HttpOnly session cookie —
(carrying ?next=, if there was one) and showing a plain-language message for not readable from here, which is the point — after which we redirect to ?next=
the failure states server/app.py's okta_callback() sends back here (T10.5). */ or the home page. The password entered is the person's WINDOWS password.
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 () { (function () {
'use strict'; 'use strict';
var errorBox = document.getElementById('error'); var errorBox = document.getElementById('error');
var okBox = document.getElementById('ok'); var okBox = document.getElementById('ok');
var signinLink = document.getElementById('okta-signin');
function byId(id) { return document.getElementById(id); } function byId(id) { return document.getElementById(id); }
@@ -18,38 +22,73 @@
errorBox.textContent = msg; errorBox.textContent = msg;
errorBox.classList.add('show'); errorBox.classList.add('show');
} }
function clearBanners() {
errorBox.classList.remove('show');
okBox.classList.remove('show');
}
// Same-site path only — mirrors the check server/app.py's _safe_next_path() // Where to go after signing in: the ?next= param if it's a safe same-site
// makes again on the way back, so a crafted ?next= can't become an open // path, otherwise the home page. (Reject absolute/scheme URLs to avoid an
// redirect even if this client-side check were somehow bypassed. // open-redirect.)
function safeNext() { function nextTarget() {
try { try {
var next = new URLSearchParams(location.search).get('next') || ''; var next = new URLSearchParams(location.search).get('next') || '';
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next; if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
} catch (e) {} } catch (e) {}
return ''; return 'index.html';
} }
if (signinLink) { function postJson(url, payload) {
var next = safeNext(); return fetch(url, {
if (next) signinLink.href = '/api/auth/okta/login?next=' + encodeURIComponent(next); method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().catch(function () { return null; }).then(function (j) {
return { status: r.status, ok: r.ok, json: j };
});
});
} }
var ERROR_MESSAGES = { function detail(res, fallback) {
disabled: 'Your account has been disabled. Contact an administrator.', var d = res && res.json && res.json.detail;
cancelled: 'Sign-in was not completed. Select the button below to try again.' return (typeof d === 'string' && d) ? d : fallback;
}; }
// ── sign in ────────────────────────────────────────────────────────────────
var form = byId('login-form');
var submitBtn = byId('submit');
// 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();
clearBanners();
var username = byId('username').value.trim();
var password = byId('password').value;
if (!username || !password) { showError('Enter your username and password.'); return; }
submitBtn.disabled = true;
submitBtn.textContent = 'Signing in…';
postJson('/api/auth/login', { username: username, password: password })
.then(function (res) {
if (res.ok) { location.replace(nextTarget()); return; }
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';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
});
});
(function showErrorFromQuery() {
try {
var code = new URLSearchParams(location.search).get('error') || '';
if (!code) return;
showError(ERROR_MESSAGES[code] || 'Sign-in was not completed. Select the button below to try again.');
// Out of the address bar once shown — an error code has no reason to
// survive a refresh or get copied along with the link.
var url = new URL(location.href);
url.searchParams.delete('error');
history.replaceState(null, '', url.pathname + url.search);
} catch (e) {}
})();
})(); })();

View File

@@ -26,6 +26,8 @@
room for "Assistant Project Manager" without pushing Actions off screen. */ 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-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); } #users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
/* The create form is a lot of fields; let them wrap and
let the project picker take a full row of its own. */
#nu-projects{ margin-top:var(--s2); } #nu-projects{ margin-top:var(--s2); }
#nu-projects .pickrow{ padding:var(--s1) var(--s1); } #nu-projects .pickrow{ padding:var(--s1) var(--s1); }
/* A manager with one project doesn't need a scrolling picker; a manager with /* A manager with one project doesn't need a scrolling picker; a manager with
@@ -80,8 +82,7 @@
<h2>Add a user</h2> <h2>Add a user</h2>
<div class="sub" id="create-sub"></div> <div class="sub" id="create-sub"></div>
<div class="urow"> <div class="urow">
<input id="nu-username" placeholder="Username *" autocomplete="off" <input id="nu-username" placeholder="Username *" autocomplete="off">
title="Must exactly match this person's Okta sign-in identity — that's how their first Okta sign-in finds this account instead of creating a second one.">
<input id="nu-fullname" placeholder="Full name" autocomplete="off"> <input id="nu-fullname" placeholder="Full name" autocomplete="off">
<input id="nu-email" placeholder="Email" autocomplete="off"> <input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role" title="Permissions — what this account may do"></select> <select id="nu-role" title="Permissions — what this account may do"></select>

View File

@@ -186,6 +186,9 @@ function managerRow(u){
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+ if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
(u.is_active?'Disable':'Enable')+'</button>'); (u.is_active?'Disable':'Enable')+'</button>');
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>'); if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</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>'); if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+ return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
@@ -329,10 +332,6 @@ function renderCreateForm(){
async function createUser(){ async function createUser(){
const msg = document.getElementById('users-create-msg'); const msg = document.getElementById('users-create-msg');
const val = id => (document.getElementById(id)||{}).value || ''; const val = id => (document.getElementById(id)||{}).value || '';
// The username entered here MUST match what Okta's identity claim will send for
// this person exactly — this creates the account ahead of their first sign-in,
// and that's how a later Okta sign-in finds this row instead of provisioning a
// second one. See create_user()'s docstring in server/app.py.
const username = val('nu-username').trim(); const username = val('nu-username').trim();
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')] const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
.map(c => c.value); .map(c => c.value);

View File

@@ -105,18 +105,6 @@
say(bits.join(''), problem); say(bits.join(''), problem);
} }
// A 500 answers plain text ("Internal Server Error"), and r.json() on that
// throws - which used to land in catch() and read as "could not reach the
// server" while the server was answering fine (found 2026-08-23, the
// production locations import). Read text, parse if it parses, keep status.
function readJson(r) {
return r.text().then(function (t) {
var j = null;
try { j = t ? JSON.parse(t) : null; } catch (e) { /* not JSON: a proxy or 500 page */ }
return { ok: r.ok, status: r.status, body: j };
});
}
function importText(dryRun) { function importText(dryRun) {
var text = (el(p + '-paste') || {}).value || ''; var text = (el(p + '-paste') || {}).value || '';
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; } if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
@@ -126,7 +114,7 @@
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ text: text, dry_run: !!dryRun }), body: JSON.stringify({ text: text, dry_run: !!dryRun }),
}) })
.then(readJson) .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
.then(function (res) { .then(function (res) {
if (!res.ok) { if (!res.ok) {
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true); say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
@@ -154,7 +142,7 @@
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(read.payload), body: JSON.stringify(read.payload),
}) })
.then(readJson) .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
.then(function (res) { .then(function (res) {
if (!res.ok) { if (!res.ok) {
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')')); setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
@@ -173,7 +161,7 @@
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(patchBody), body: JSON.stringify(patchBody),
}) })
.then(readJson) .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); })
.then(function (res) { .then(function (res) {
if (!res.ok) { if (!res.ok) {
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true); say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);

View File

@@ -12,48 +12,48 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
# CORS_ORIGINS=http://localhost:5500 # CORS_ORIGINS=http://localhost:5500
# ── Authentication ──────────────────────────────────────────────────────────── # ── Authentication ────────────────────────────────────────────────────────────
# There is no local password (D15/D16) — Okta OIDC is the only way in. Sign-in # Secret used to sign session cookies (JWTs). REQUIRED in production: if unset,
# still ends the same way it always did: a signed JWT in an HttpOnly session # the API falls back to a random per-process key, so logins reset on every
# cookie, which is what the four vars right below this line are for. The five # restart and break across multiple gunicorn workers. Generate a strong one:
# OKTA_* vars after that are what makes the actual sign-in possible; without
# them the API starts (this is not a hard failure like AUTH_SECRET_KEY), but
# describe()'s startup log line says so and nobody can sign in.
# Secret used to sign session cookies (JWTs), AFTER Okta has confirmed who
# someone is — this app still decides roles/authorization locally, unchanged
# by Okta (see server/okta_auth.py). REQUIRED in production: if unset, the API
# falls back to a random per-process key, so logins reset on every restart and
# break across multiple gunicorn workers. Generate a strong one:
# python -c "import secrets; print(secrets.token_urlsafe(48))" # python -c "import secrets; print(secrets.token_urlsafe(48))"
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12. # How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12 # AUTH_SESSION_HOURS=12
# ── Okta OIDC (required — this is the only sign-in path) ─────────────────────── # ── Domain authentication, D13 (REQUIRED — there is no fallback) ───────────────
# The Okta *authorization server* issuer, e.g. https://yourorg.okta.com/oauth2/default # The suite stores no passwords. Sign-in is an LDAPS simple bind against the
# or a custom authorization server URL. The API discovers the authorize/token/ # domain, so if this is misconfigured NOBODY CAN SIGN IN, admins included. There
# jwks endpoints from <OKTA_ISSUER>/.well-known/openid-configuration — nothing # is deliberately no local break-glass account (decided Aug 21 2026 — see
# else about Okta's endpoints is hand-entered. # docs/waves/decisions-2026-08-21.md). Check `docker compose logs api` on startup:
OKTA_ISSUER=https://your-org.okta.com/oauth2/default # 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
# Client ID and secret from the Okta app integration (Sign-in method: OIDC - # Trust anchor: PRIME CONTROLS ROOT CA + PRIME CONTROLS ISSUING CA 1 as a PEM
# Authorization Code, Application type: Web Application). The secret is exactly # bundle. These are PUBLIC certificates — no private key, nothing issued to this
# that — treat it like AUTH_SECRET_KEY, never commit it. # app, nothing to request from IT. The repo ships a verified copy and the default
OKTA_CLIENT_ID=CHANGE_ME # points at it, so you only set this to override with a mounted file.
OKTA_CLIENT_SECRET=CHANGE_ME # LDAP_CA_FILE=/app/server/certs/prime-ca-chain.pem
# Must exactly match a "Sign-in redirect URI" registered on the Okta app # An AD group required to sign in. Empty means every domain account may sign in.
# integration, scheme and path included, e.g.: # This is the INITIAL value and the fallback; the live value is set in the Admin
# https://wp-suite.company.local/api/auth/okta/callback # console, which refuses to save a group that does not resolve or that the saving
OKTA_REDIRECT_URI=CHANGE_ME # admin is not a member of. Nested groups count.
# LDAP_REQUIRED_GROUP=WP-Suite-Users
# Which ID token claim carries this person's directory identity, matched # Bind/connect timeout, and how many extra CONNECT attempts to make. Retries never
# against the local users.username column (server/app.py's okta_callback()). # apply to a rejected password — each failed bind counts against the domain lockout
# preferred_username is Okta's usual default for an AD-imported user; override # policy, so guessing would lock real accounts out of Windows.
# it if your security team's Okta configuration uses a different claim (upn, # LDAP_TIMEOUT_SECONDS=8
# a custom claim, …) — no code change needed, just this value. # LDAP_CONNECT_RETRIES=2
# OKTA_IDENTITY_CLAIM=preferred_username
# ── Email notifications (optional) ───────────────────────────────────────────── # ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin # WP-assignment emails are OFF by default and are turned on from the Admin

View File

@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| GET | `/api/health` | liveness check (unauthenticated) | | GET | `/api/health` | liveness check (unauthenticated) |
| GET | `/api/auth/okta/login` | redirects the browser to Okta's authorize endpoint (`?next=` optional) | | POST | `/api/auth/login` | sign in (`{username, password}`) — binds against the domain, sets the session cookie |
| GET | `/api/auth/okta/callback` | Okta redirects back here with the auth code; signs the person in |
| POST | `/api/auth/logout` | clear the session cookie | | POST | `/api/auth/logout` | clear the session cookie |
| GET | `/api/auth/me` | the logged-in user | | GET | `/api/auth/me` | the logged-in user |
| GET | `/api/auth/users` | list accounts (**admin**) | | GET | `/api/auth/users` | list accounts (**admin**) |
| POST | `/api/auth/users` | pre-create an account by username (**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**) | | DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
| POST | `/api/sops` | create/update a SOP (upsert by `id`) | | POST | `/api/sops` | create/update a SOP (upsert by `id`) |
| GET | `/api/sops` | list SOP summaries | | GET | `/api/sops` | list SOP summaries |
@@ -40,86 +40,135 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
--- ---
## Sign-in (Okta) ## Sign-in (domain authentication, D13)
There is no local password anywhere in this app (D15/D16) — Okta OIDC is the **The suite stores no passwords.** Signing in performs an LDAPS **simple bind** to
only way in. `login.html` is a single "Sign in with Okta" button; the actual `ldaps://prime.local:636` as `<sAMAccountName>@prime.local` using the password the
exchange is `server/okta_auth.py` (the Okta client config) and the two routes person typed — their Windows password. A successful bind is the authentication.
in `server/app.py`: `okta_login()` sends the browser to Okta's authorize See `server/ldap_auth.py`; the schema has no `password_hash` column.
endpoint, `okta_callback()` exchanges the code, matches the ID token's identity
claim against `users.username`, and signs the person in.
Sign-in still ends the same way it always did: a signed JWT in an **HttpOnly, Sign-in issues a signed JWT
SameSite=Lax** cookie (`wp_session`), marked **Secure** automatically whenever that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is
the request arrives over HTTPS (via NGINX's `X-Forwarded-Proto`). There is no marked **Secure** automatically whenever the request arrives over HTTPS (via
server-side session store — each request is validated by checking the cookie's NGINX's `X-Forwarded-Proto`). There is no server-side session store — each
signature and expiry. Okta only confirms *who* someone is; this app still request is validated by checking the cookie's signature and expiry.
decides *what* they may do — roles, project membership, everything below stays
local and unchanged by Okta.
**The real security boundary is the API:** every `/api/` data route is refused **The real security boundary is the API:** every `/api/` data route is refused
with `401` unless a valid session cookie is present (see `auth_gate` in 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 `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. to `login.html` when there's no session — that's for UX, not protection.
**Access gating is Okta's job, not this app's.** Only accounts assigned to the **The directory supplies identity; this app supplies authorization.** Roles live in
app integration in Okta can complete the sign-in flow at all, so there is no the local `users` table and are never read from AD — so an existing admin stays an
required-group or claim check layered on top here. Once Okta lets someone admin. Roles are `admin`, `project_super_user`, `project_admin`, `project_user`.
through, this app decides their role — see below.
Roles are `admin`, `project_super_user`, `project_admin`, `project_user` **Accounts are created on first successful sign-in.** Anyone who binds successfully
(`html/users.js`, enforced server-side). 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.
### Set the signing secret and the Okta app integration **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.
Add `AUTH_SECRET_KEY` and the five `OKTA_*` variables to `.env` — see **There is no password reset and no break-glass.** The login page links to
`.env.example` for what each one is and where it comes from. `AUTH_SECRET_KEY` `https://primecontrols.okta.com/` for password self-service. If the domain is
is **required in production**: without it the API uses a random per-process unreachable, or `LDAP_CA_FILE` is wrong, or the required group is misconfigured,
key, so logins reset on restart. The `OKTA_*` variables are not a hard-fail the **nobody can sign in, including admins** — the API logs one line at startup saying
same way — the API starts without them, it just refuses every sign-in and says whether LDAP is configured and reachable, so check `docker compose logs api` first.
so in the startup log (`okta_auth.describe()`).
**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
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production**
without it the API uses a random per-process key, so logins reset on restart.
```bash ```bash
python -c "import secrets; print(secrets.token_urlsafe(48))" python -c "import secrets; print(secrets.token_urlsafe(48))"
``` ```
The Okta app integration itself (sign-in method OIDC, Application type Web ### Bootstrap the first admin
Application) needs its **Sign-in redirect URI** set to exactly
`OKTA_REDIRECT_URI`'s value, and the people who should have access assigned to
it — that assignment IS the access control (see above).
### Create the first admin Two steps, in this order. There is no `create-admin` any more — there is no password
to set and no account to create.
There's no `create-admin` command anymore — creating an account from scratch
by hand-typed username risks a second, orphaned row if it doesn't exactly match
what Okta actually sends (see `OKTA_IDENTITY_CLAIM` in `.env.example`). Instead,
have the first admin **sign in through Okta once** — they land as an ordinary
`project_user`, JIT-provisioned — then promote that existing row from a shell
(run from the **project root**, like uvicorn):
```bash ```bash
python -m server.manage_users promote alice --role admin # 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 Other commands: `list`, `promote <user> [--role …]`, `demote <user>`,
docker compose exec api python -m server.manage_users promote alice --role admin `disable <user>`, `enable <user>`. After that, admins manage accounts from the Admin
``` console.
Other commands: `list`, `disable <user>`, `enable <user>`. After the first **Every command that changes anything requires a domain bind** (D14), prompted —
admin exists, they can promote others through the User Directory page (or keep there is deliberately no `--password` flag, which would put a live domain password
using the CLI) — no shell access needed for anyone after the first. 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.
**No break-glass path.** If Okta is unreachable or misconfigured, the app is Be clear on what the bind is worth: anyone with a shell here can still write to the
unreachable for everyone, including admins, until Okta is restored (D16) — this `users` table with `psql`. It is defence in depth and, mostly, **accountability**
is a deliberate choice, the same one the abandoned LDAPS design made, not an every role change now writes an audit row naming a person, which shell changes
oversight. previously did not.
--- ---
## Local dev ## 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 ```bash
cd server cd server
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
@@ -322,25 +371,25 @@ docker compose down -v
## Quick test ## Quick test
`/api/health` is open; every other `/api/` route needs a session cookie: `/api/health` is open; data routes now require a session, so log in first and
reuse the cookie jar:
```bash ```bash
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
# Sign in, saving the session cookie to a jar
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"<password>"}'
# Reuse the cookie on protected routes
curl -b jar.txt http://127.0.0.1:8000/api/comments
``` ```
Without a session cookie, protected routes return `401 {"detail":"Not authenticated"}`. Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`.
Or via the nginx proxy (replace with your hostname): Or via the nginx proxy (replace with your hostname):
```bash ```bash
curl https://wp-suite.company.local/api/health curl https://wp-suite.company.local/api/health
``` ```
There's no `curl`-able login anymore — Okta requires a real browser to
complete, which is what `login.html`'s "Sign in with Okta" button is for. To
exercise a protected route from a script instead, use `server/smoketest.py`'s
own technique (mint a session with `auth.create_token()` and set it as the
`wp_session` cookie, the same thing `okta_callback()` does after Okta hands
back an identity) rather than reaching for curl by hand — see that script's
own AUTHENTICATION section for the exact steps, and why it has to run
somewhere that shares the target server's `AUTH_SECRET_KEY` and database.

View File

@@ -52,13 +52,6 @@ def run_migrations_online() -> None:
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
compare_type=True, compare_type=True,
# Each migration commits on its own. One transaction for the WHOLE
# run meant a crash at step N rolled back steps 1..N-1 while their
# "Running upgrade" lines stayed on screen claiming they ran - the
# 2026-08-21 outage's stamp-to-head repair trusted those lines and
# left production missing two tables (found 2026-08-23 when the
# locations import 500'd on a table that "had been created").
transaction_per_migration=True,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()

View File

@@ -1,29 +0,0 @@
"""drop local password (T10.4, wave 10 / D15 / D16)
Real deletion, not a toggle: no local password exists anymore, only Okta OIDC.
`password_hash` was NOT NULL at the database level since the baseline schema, so
downgrade re-adds it with server_default='' rather than leaving existing rows
without a value — the same pattern used for the locale/timezone drop-precedent
columns, applied in reverse.
Revision ID: 1d60a608bb51
Revises: a1b8c6d4e2f9
Create Date: 2026-09-03 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1d60a608bb51'
down_revision = 'a1b8c6d4e2f9'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column('users', 'password_hash')
def downgrade() -> None:
op.add_column('users', sa.Column('password_hash', sa.String(length=200), nullable=False, server_default=''))

View File

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

@@ -10,26 +10,29 @@ Interactive docs: http://<host>/api/docs
""" """
import base64 import base64
import logging import logging
from contextlib import asynccontextmanager
import os import os
import re import re
import uuid import uuid
from datetime import timedelta, timezone
from time import monotonic
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from authlib.integrations.base_client import OAuthError
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, delete, func from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from .db import Base, engine, get_db from .db import Base, engine, get_db
from . import models, auth, notify, assets_db, okta_auth, okta_fake from . import models, auth, notify, ldap_auth, assets_db
log = logging.getLogger("wpsuite.app") # 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: # Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run. # • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -42,7 +45,30 @@ if engine.dialect.name == "sqlite":
# Interactive docs are handy in dev but hand an attacker the full API map in prod, # 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). # so enable them only on the SQLite dev fallback (production runs on Postgres).
_docs_enabled = engine.dialect.name == "sqlite" _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( app = FastAPI(
lifespan=_lifespan,
title="Work Package Suite API", title="Work Package Suite API",
docs_url="/api/docs" if _docs_enabled else None, docs_url="/api/docs" if _docs_enabled else None,
redoc_url=None, redoc_url=None,
@@ -59,22 +85,6 @@ if _origins:
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"], allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"],
) )
# Authlib's Okta client needs request.session to carry the OIDC state/nonce (and,
# below, our own post-login redirect target) across the round trip to Okta and
# back — it raises an AssertionError without this. Bug found in T10.2 (those
# routes never crashed in testing because every prior check mocked
# authorize_redirect/authorize_access_token directly, bypassing Authlib's real
# implementation); fixed here rather than reworking already-shipped T10.2 code.
#
# This is NOT the app's session cookie — wp_session (auth.py) still carries the
# actual signed-in identity, unchanged. This cookie holds nothing but ephemeral,
# per-attempt OAuth state, so it gets a short lifetime and a plain secret reuse
# (auth.SECRET_KEY) rather than its own required config knob.
app.add_middleware(
SessionMiddleware, secret_key=auth.SECRET_KEY, session_cookie="wp_oauth_state",
same_site="lax", https_only=False, max_age=600,
)
# ── Authentication gate ──────────────────────────────────────────────────────── # ── Authentication gate ────────────────────────────────────────────────────────
# Every /api/ data route requires a valid session cookie. Login, health, and the # Every /api/ data route requires a valid session cookie. Login, health, and the
@@ -624,7 +634,15 @@ def health():
# ── Authentication ───────────────────────────────────────────────────────────── # ── Authentication ─────────────────────────────────────────────────────────────
class LoginIn(BaseModel):
username: str
password: str
class NewUserIn(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 username: str
full_name: str = "" full_name: str = ""
email: str = "" email: str = ""
@@ -669,145 +687,225 @@ class AutoAddIn(BaseModel):
role: str = "" role: str = ""
# 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)):
"""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()
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
if locked is not None and locked > now:
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
# ── 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, "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()}
@app.post("/api/auth/logout") @app.post("/api/auth/logout")
def logout(response: Response): def logout(response: Response):
auth.clear_session_cookie(response) auth.clear_session_cookie(response)
return {"ok": True} return {"ok": True}
# ── Okta OIDC sign-in (T10.2, wave 10 / D15) ────────────────────────────────────
# Access gating is Okta's job: only accounts assigned to this app integration in Okta
# can complete authorize_redirect at all. No app-side group/claim check is layered on
# top here — see okta_auth.py's docstring and wave-10.md T10.2 for why.
def _safe_next_path(raw: str) -> str:
"""A same-site path only — same rule login.js's own nextTarget() enforces
client-side. Rejects absolute/scheme URLs ('//evil.com', 'https://evil.com')
so a crafted ?next= can't turn a real Okta sign-in into an open redirect."""
raw = (raw or "").strip()
if raw and raw.startswith("/") and not raw.startswith("//"):
return raw
return ""
@app.get("/api/auth/okta/login")
async def okta_login(request: Request):
"""Send the browser to Okta's authorize endpoint. Where to land afterward
(?next=, e.g. from a deep link an assignment email carried — X1/CR-011/CR-014)
rides in the OAuth-state session cookie alongside Authlib's own state/nonce,
since nothing else survives the round trip to Okta and back."""
if not okta_auth.oauth:
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
next_path = _safe_next_path(request.query_params.get("next", ""))
if next_path:
request.session["post_login_redirect"] = next_path
return await okta_auth.oauth.okta.authorize_redirect(request, okta_auth.REDIRECT_URI)
@app.get("/api/auth/okta/callback")
async def okta_callback(request: Request, db: Session = Depends(get_db)):
"""Exchange the authorization code for tokens, validate the ID token, and sign the
person in. T10.3: matches the identity claim to a local account, or JIT-provisions
one, then issues the same session cookie login() does today.
Failure paths land back on the login page with a plain-language ?error= instead
of a raw HTTPException — this route is reached by a full-page browser navigation
from Okta, not a fetch() call, so a JSON error body is just a broken-looking page
to whoever is sitting at the keyboard (T10.5)."""
if not okta_auth.oauth:
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
try:
token = await okta_auth.oauth.okta.authorize_access_token(request)
except OAuthError as exc:
log.warning("Okta callback rejected: %s", exc)
return RedirectResponse(url="/login.html?error=cancelled", status_code=303)
claims = token.get("userinfo") or {}
identity = (claims.get(okta_auth.IDENTITY_CLAIM) or "").strip()
if not identity:
log.error("Okta ID token had no %r claim — check OKTA_IDENTITY_CLAIM", okta_auth.IDENTITY_CLAIM)
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
user = auth.find_user(db, identity)
if user is None:
# JIT provisioning (D15). Okta is the only gate on WHO can reach this route
# at all — this app still decides what a first-time sign-in may do. A new
# account gets the lowest-privilege role and no project membership; an admin
# or project super user grants access afterward, same as any account created
# by hand today (create_user() above). No password field exists at all —
# Okta is the only credential (D15/D16, real deletion as of T10.4).
user = models.User(
id=gen_id("user"),
username=identity,
email=(claims.get("email") or "").strip(),
full_name=(claims.get("name") or "").strip(),
role=auth.ROLE_PROJECT_USER,
)
db.add(user)
db.flush()
log_event(db, user.username, "user_created", "user", user.id, summary=user.username,
detail={"role": user.role, "via": "okta_jit"})
elif not user.is_active:
# Deprovisioning stays local (D15's "roles stay local"): Okta letting someone
# through does not override an account this app has disabled. Same rule
# login() enforced today, now surfaced as a login-page banner (T10.5)
# instead of a raw 403 body, for the reason in this route's docstring.
return RedirectResponse(url="/login.html?error=disabled", status_code=303)
user.failed_attempts = 0
user.locked_until = None
user.last_login_at = models.utcnow()
db.commit()
db.refresh(user)
tok = auth.create_token(user)
target = _safe_next_path(request.session.pop("post_login_redirect", "")) or "/index.html"
redirect = RedirectResponse(url=target, status_code=303)
auth.set_session_cookie(redirect, request, tok)
return redirect
# ── Fake Okta test seam (T10.7) ──────────────────────────────────────────────
# Registered ONLY when the fake is active — checked once, at import time, same
# timing okta_auth.oauth itself is built at. In production these two routes do
# not exist at all, not merely refuse a request: see server/okta_fake.py's
# docstring for why that distinction matters given D16 leaves no other way in.
if okta_fake.is_active():
@app.get("/api/auth/okta/_fake_provider")
async def okta_fake_provider(request: Request):
"""Stands in for Okta's own sign-in screen. A plain list of the
identities WP_OKTA_FAKE_DIRECTORY defines, so a browser check drives a
real page through a real round trip rather than skipping it."""
state = request.query_params.get("state", "")
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
from fastapi.responses import HTMLResponse
return HTMLResponse(okta_fake.picker_page(state, redirect_uri))
@app.get("/api/auth/okta/_fake_provider/consent")
async def okta_fake_consent(request: Request):
"""What clicking an identity (or Deny) on the fake picker does: hands
back an authorization code (or an error) at okta_callback, exactly the
shape a real Okta redirect would carry. Everything after this — the
state check, JIT provisioning, the disabled-account and open-redirect
guards — is the real okta_callback() above, unmodified."""
state = request.query_params.get("state", "")
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
if request.query_params.get("deny"):
return RedirectResponse(
url=f"{redirect_uri}?error=access_denied&error_description=denied+by+fake+user&state={state}",
status_code=303)
username = request.query_params.get("username", "")
entry = okta_fake.directory().get(username)
if not isinstance(entry, dict):
return RedirectResponse(
url=f"{redirect_uri}?error=invalid_request&error_description=unknown+fake+identity&state={state}",
status_code=303)
claims = {okta_auth.IDENTITY_CLAIM: username,
"email": entry.get("email", ""), "name": entry.get("name", "")}
code = okta_fake.new_code(claims)
return RedirectResponse(url=f"{redirect_uri}?code={code}&state={state}", status_code=303)
@app.get("/api/auth/me") @app.get("/api/auth/me")
def whoami(user: models.User = Depends(auth.get_current_user)): def whoami(user: models.User = Depends(auth.get_current_user)):
"""Who is logged in. The frontend guard calls this on every page load. """Who is logged in. The frontend guard calls this on every page load.
@@ -948,14 +1046,6 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
@app.post("/api/auth/users") @app.post("/api/auth/users")
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
"""Create an account ahead of its first Okta sign-in — e.g. to put it on
projects or hand it a role before anyone has ever signed in as them.
`body.username` MUST match what Okta's identity claim will send for this
person exactly (see OKTA_IDENTITY_CLAIM, server/okta_auth.py) — auth.find_user()
is how a later Okta sign-in locates this row (T10.3). A mismatch doesn't
fail loudly; it silently produces a second, JIT-provisioned account instead
of signing this person into the one just created here."""
allowed = grantable_roles(actor) allowed = grantable_roles(actor)
if body.role not in allowed: if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}") raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
@@ -2336,24 +2426,6 @@ def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[di
rejected.append({"line": i, "text": line, rejected.append({"line": i, "text": line,
"reason": "no letters or digits to make a code from"}) "reason": "no letters or digits to make a code from"})
continue continue
# Postgres enforces VARCHAR lengths and refuses NUL/control bytes;
# SQLite shrugs at both - which is how ONE bad CSV line 500'd the whole
# production import (2026-08-23, BL-027's class again) instead of coming
# back as a rejection with its line number. Validate per row, here,
# so every dialect answers the same way: with a reason.
if any(any(ord(ch) < 32 for ch in p) for p in parts):
rejected.append({"line": i, "text": line[:120],
"reason": "contains control characters — re-save the file as plain CSV (UTF-8)"})
continue
long_p = next((p for p in parts if len(p) > 200), None)
if long_p is not None:
rejected.append({"line": i, "text": line[:120],
"reason": "a name is longer than 200 characters (%d)" % len(long_p)})
continue
if any(len(location_slug(p)) > 60 for p in parts):
rejected.append({"line": i, "text": line[:120],
"reason": "a code would be longer than 60 characters"})
continue
rows.append((i, parts)) rows.append((i, parts))
return rows, rejected return rows, rejected
@@ -2420,7 +2492,6 @@ def import_locations(project_id: str, body: LocationImportIn,
require_project_writable(db, user, project_id, "The location list cannot be changed") require_project_writable(db, user, project_id, "The location list cannot be changed")
rows, rejected = parse_location_rows(body.text) rows, rejected = parse_location_rows(body.text)
read_total = len(rows) + len(rejected)
existing = {n.path: n for n in db.scalars( existing = {n.path: n for n in db.scalars(
select(models.LocationNode).where(models.LocationNode.project_id == project_id) select(models.LocationNode).where(models.LocationNode.project_id == project_id)
@@ -2435,10 +2506,6 @@ def import_locations(project_id: str, body: LocationImportIn,
for line_no, parts in rows: for line_no, parts in rows:
segs = [location_slug(p) for p in parts] segs = [location_slug(p) for p in parts]
full = "/".join(segs) full = "/".join(segs)
if len(full) > 200:
rejected.append({"line": line_no, "text": "/".join(parts)[:120],
"reason": "the combined path is longer than 200 characters"})
continue
if full in seen_in_file: if full in seen_in_file:
duplicates.append({"line": line_no, "path": full, "names": parts, duplicates.append({"line": line_no, "path": full, "names": parts,
"reason": "already on line %d of this import" % seen_in_file[full]}) "reason": "already on line %d of this import" % seen_in_file[full]})
@@ -2481,7 +2548,7 @@ def import_locations(project_id: str, body: LocationImportIn,
result = { result = {
"project_id": project_id, "dry_run": bool(body.dry_run), "project_id": project_id, "dry_run": bool(body.dry_run),
"read": read_total, "read": len(rows) + len(rejected),
"created": created, "duplicates": duplicates, "created": created, "duplicates": duplicates,
"reactivated": reactivated, "rejected": rejected, "reactivated": reactivated, "rejected": rejected,
} }
@@ -2978,18 +3045,6 @@ def parse_material_rows(text: str):
rejected.append({"line": i, "text": raw.strip()[:120], rejected.append({"line": i, "text": raw.strip()[:120],
"reason": "more than three columns - description, unit, code is the whole shape"}) "reason": "more than three columns - description, unit, code is the whole shape"})
continue continue
# Same guard as parse_location_rows: reject what Postgres would refuse
# (VARCHAR limits, control bytes) with the line number, never a 500.
if any(any(ord(ch) < 32 for ch in p) for p in parts):
rejected.append({"line": i, "text": raw.strip()[:120],
"reason": "contains control characters - re-save the file as plain CSV (UTF-8)"})
continue
caps = ((300, "description"), (20, "unit"), (80, "code"))
long_col = next((("%s is longer than %d characters (%d)" % (label, cap, len(p)))
for (cap, label), p in zip(caps, parts) if len(p) > cap), None)
if long_col:
rejected.append({"line": i, "text": raw.strip()[:120], "reason": long_col})
continue
rows.append((i, parts)) rows.append((i, parts))
return rows, rejected return rows, rejected

View File

@@ -1,11 +1,10 @@
"""Authentication for the Work Package Suite. """Authentication for the Work Package Suite.
Identity is confirmed by Okta (OIDC authorization-code flow, see server/okta_auth.py Authentication is an LDAPS simple bind against the domain (D13); this module owns
and the routes in server/app.py); there is no local password anywhere in this app everything *after* that. A successful sign-in issues a signed JWT that rides in an
(D15, D16 — T10.4 removed the last of it). A successful sign-in issues a signed JWT HttpOnly cookie (`wp_session`). Because the token is signed and self-validating, there is no
that rides in an HttpOnly cookie (`wp_session`). Because the token is signed and server-side session store — every request is checked by verifying the cookie's
self-validating, there is no server-side session store — every request is checked by signature and expiry (see `auth_gate` and `get_current_user`).
verifying the cookie's signature and expiry (see `auth_gate` and `get_current_user`).
Security model: Security model:
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data • The real boundary is `auth_gate` (middleware in app.py): every /api/ data
@@ -13,14 +12,14 @@ Security model:
• The cookie is HttpOnly (JS can't read it → XSS can't steal the session), • 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 SameSite=Lax (blunts CSRF), and Secure whenever the request arrives over
HTTPS (detected via X-Forwarded-Proto behind NGINX). 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 • 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 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. warning and invalidates every session on restart) so dev still works.
Permissions roles (`User.role`) — distinct from a person's job function on the Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing. Decided entirely project, which lives in `User.project_role` and grants nothing:
locally: Okta gates WHO can authenticate at all, this app decides what an
authenticated account may do — see D15/D16.
• admin application administrator: user administration, app settings, • admin application administrator: user administration, app settings,
and implicit access to every project. and implicit access to every project.
• project_super_user • project_super_user
@@ -33,13 +32,16 @@ authenticated account may do — see D15/D16.
modify a SOP after it has been completed, and delete projects. modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP • project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP. up to completion. May NOT delete WPs or change a completed SOP.
Also where Okta JIT provisioning (T10.3) lands a brand-new
account — the lowest-privilege role, promoted locally from
there by an admin (see manage_users.py for the bootstrap case).
The user-administration SCOPE of a super user is worked out in server/app.py 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 (`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all. membership rows — this module only decides which roles carry the power at all.
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 os
import secrets import secrets
@@ -59,7 +61,7 @@ log = logging.getLogger("wpsuite.auth")
COOKIE_NAME = "wp_session" COOKIE_NAME = "wp_session"
JWT_ALG = "HS256" JWT_ALG = "HS256"
# How long a session lasts before the person must sign in again. # How long a login lasts before the user must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12")) SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# ── permissions roles ───────────────────────────────────────────────────────── # ── permissions roles ─────────────────────────────────────────────────────────
@@ -119,7 +121,7 @@ def is_project_admin(user: "models.User") -> bool:
# same question used to exist here and silently disagreed with the scoped one, which # 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. # locked per-project super users out of the routes they were entitled to.
# Paths under /api that do NOT require a session (the Okta routes themselves, health, docs). # Paths under /api that do NOT require a session (login itself, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",) _EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = { _EXEMPT_EXACT = {
"/api/health", "/api/health",
@@ -180,10 +182,7 @@ def decode_token(token: str) -> Optional[dict]:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError: except jwt.PyJWTError:
return None return None
# No route issues a `typ`-carrying token anymore (that was the password-reset # A password-reset token must never be usable as a session cookie.
# token, removed in T10.4), but a session token still must never validate as
# one — kept as a defensive check, cheap insurance against a future token type
# riding the same cookie.
if claims.get("typ"): if claims.get("typ"):
return None return None
return claims return claims

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,107 +1,224 @@
"""Command-line user management for the Work Package Suite. """Command-line user management for the Work Package Suite.
There is no local password (D15) and no local account creation from here anymore Accounts are not created here any more. D13 provisions them on first successful
(D16, T10.4) — accounts are created by signing in through Okta, which JIT- sign-in, so this tool exists to do the one thing the directory cannot decide:
provisions a row at the lowest-privilege role (see server/okta_auth.py, assign the app's PERMISSIONS role. The directory supplies identity; this supplies
server/app.py's okta_callback(), wave-10.md T10.3). This tool's job is narrower authorization.
now: change the role on an account that already exists, and do routine account
maintenance from a shell on the server.
That narrower job is still how the very first admin gets named (D16): have that
person sign in through Okta once — they land as project_user — then promote them
from here. Promoting an existing row, rather than creating one blind, matters
because it never has to guess the exact string Okta will send as the identity
claim; a hand-typed username that doesn't match it exactly would just produce a
second, orphaned account instead of the one you meant to promote.
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 promote alice --role admin
python -m server.manage_users list python -m server.manage_users list
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 disable bob
python -m server.manage_users enable bob python -m server.manage_users enable bob
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 argparse
import getpass
import sys import sys
import uuid import uuid
from .db import SessionLocal, Base, engine from .db import SessionLocal, Base, engine
from . import models, auth from . import models, auth, ldap_auth
def cmd_promote(args) -> None: def _gen_id(prefix: str = "user") -> str:
role = args.role return f"{prefix}_{uuid.uuid4().hex[:12]}"
# 'user' is the pre-roles spelling of 'project_user', accepted here so a
# documented one-liner from before this rework keeps working.
if role == "user": def _audit(db, actor: str, action: str, user: "models.User", detail: dict) -> None:
role = auth.ROLE_PROJECT_USER """Append an audit row in the caller's transaction.
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}") Written by hand rather than via app.py's log_event: importing that would drag
with SessionLocal() as db: FastAPI and the entire application into a CLI startup for one INSERT.
u = auth.find_user(db, args.username) """
if not u: db.add(models.AuditLog(
sys.exit( id=_gen_id("ev"), actor=actor, action=action, entity_type="user",
f"No user named '{args.username}'. This promotes an existing account, it " entity_id=user.id, summary=user.username, detail=detail,
f"doesn't create one — they need to sign in through Okta at least once first." ))
)
old_role = u.role
u.role = role def authenticate_operator() -> str:
# Audited the same way a role change from the web Admin Console already is """Prompt for a domain credential, bind, and return the operator's sAMAccountName.
# (server/app.py's set_user_role() -> log_event(), action "role_changed") —
# this command changes the same field and previously left no record of who Exits on failure — a command that changes a role must not proceed unauthenticated.
# ran it or what it changed (T10.10). "actor" can't name a real person here: The password is only ever read from a hidden prompt: there is no --password flag,
# a container shell exec carries no signed-in identity to attribute it to, because that would put a live domain password into shell history and into the
# so it's tagged as the tool itself rather than guessing. "via" mirrors JIT output of `ps` for every other user on the box.
# provisioning's own tag on user_created events. """
db.add(models.AuditLog( if not ldap_auth.is_configured():
id=f"ev_{uuid.uuid4().hex[:12]}", sys.exit(f"Cannot authenticate: {ldap_auth.describe()}\n"
actor="cli:manage_users", f"This command needs a domain bind. Fix the LDAP configuration first.")
action="role_changed", who = input("Your domain username: ").strip()
entity_type="user", if not who:
entity_id=u.id, sys.exit("Cancelled.")
summary=u.username, pw = getpass.getpass("Your domain password: ")
detail={"from": old_role, "to": role, "via": "cli"}, if not pw:
)) sys.exit("Cancelled.")
db.commit() # required_group="" on purpose: see the module docstring. The login group must
print(f"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.") # 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 _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: 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: with SessionLocal() as db:
rows = db.query(models.User).order_by(models.User.username).all() rows = db.query(models.User).order_by(models.User.username).all()
if not rows: if not rows:
print("No users yet. Accounts appear here once someone signs in through Okta.") print("No accounts yet. They are created on first successful sign-in.")
return 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: 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}" 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 _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 = _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"{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: def _set_active(username: str, active: bool) -> None:
operator = authenticate_operator()
with SessionLocal() as db: with SessionLocal() as db:
u = auth.find_user(db, username) u = _load(db, username)
if not u: if bool(u.is_active) == active:
sys.exit(f"No user named '{username}'.") print(f"{u.username} is already {'enabled' if active else 'disabled'}.")
return
u.is_active = active 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() db.commit()
print(f"{u.username} is now {'enabled' if active else 'disabled'}.") print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
def main() -> None: 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) 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) sub = p.add_subparsers(dest="cmd", required=True)
pr = sub.add_parser("promote", help="change an existing account's role (e.g. name the first admin)") sub.add_parser("list", help="list all accounts (no credential needed)")
pr.add_argument("username")
pr.add_argument("--role", required=True, choices=list(auth.ROLES) + ["user"],
help="permissions role ('user' is the legacy name for project_user)")
sub.add_parser("list", help="list all accounts") 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)")
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")
dp = sub.add_parser("disable", help="disable an account (blocks sign-in)") dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
dp.add_argument("username") dp.add_argument("username")
@@ -109,10 +226,12 @@ def main() -> None:
ep.add_argument("username") ep.add_argument("username")
args = p.parse_args() args = p.parse_args()
if args.cmd == "promote": if args.cmd == "list":
cmd_promote(args)
elif args.cmd == "list":
cmd_list(args) cmd_list(args)
elif args.cmd == "promote":
cmd_promote(args)
elif args.cmd == "demote":
cmd_demote(args)
elif args.cmd == "disable": elif args.cmd == "disable":
_set_active(args.username, False) _set_active(args.username, False)
elif args.cmd == "enable": elif args.cmd == "enable":

View File

@@ -142,10 +142,14 @@ class WorkPackage(Base):
class User(Base): class User(Base):
"""A login account. No password is stored here or anywhere else — identity is """A login account. NO PASSWORD IS STORED — D13 moved authentication to an
confirmed by Okta (OIDC), this app only decides what the account may do once LDAPS bind against the domain (see server/ldap_auth.py), and the
Okta has vouched for it (see server/okta_auth.py, server/auth.py, D15/D16). `password_hash` column was dropped. `username` is the sAMAccountName people
`username` is what Okta's identity claim resolves to. 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: Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app. • role the PERMISSIONS role — what the account may do in the app.
@@ -183,12 +187,14 @@ class User(Base):
# Online-guessing throttle (see login()): consecutive failures + a lockout window. # Online-guessing throttle (see login()): consecutive failures + a lockout window.
failed_attempts: Mapped[int] = mapped_column(Integer, default=0) failed_attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) 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 # Bumped to invalidate all existing sessions for this user. Password changes no
# change). The value is embedded in the JWT and re-checked on every request. # 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) token_version: Mapped[int] = mapped_column(Integer, default=0)
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Public view of a user — NEVER includes the password hash.""" """Public view of a user."""
return { return {
"id": self.id, "username": self.username, "email": self.email, "id": self.id, "username": self.username, "email": self.email,
"full_name": self.full_name, "role": self.role, "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 # 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 # 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") PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
@@ -83,13 +84,9 @@ def public_settings(db: Session) -> dict:
def app_flags(db: Session) -> dict: def app_flags(db: Session) -> dict:
"""Feature flags for any signed-in user (no secrets, no SMTP detail). """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."""
s = get_settings(db) s = get_settings(db)
out = {k: s.get(k) for k in PUBLIC_KEYS} return {k: s.get(k) for k in PUBLIC_KEYS}
out["password_reset_enabled"] = bool(s.get("email_enabled")) and smtp_ready(s)
return out
def smtp_ready(s: dict) -> bool: 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) 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, 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": 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 + """Record a notification. Marked 'pending' only if email is enabled + SMTP ready +

View File

@@ -1,109 +0,0 @@
"""Okta OIDC client configuration — T10.1, wave 10 (D15).
This module owns the conversation with Okta and nothing else: it does not issue this
app's own session cookie, does not touch the database, and does not decide who may sign
in. `server/auth.py` keeps doing all of that, unchanged — a session is still a signed JWT
in an HttpOnly cookie, roles are still local, `get_current_user` still re-reads the
account on every request. Only how a person's identity gets confirmed changes: an Okta
authorization-code flow, in place of the local username/password check.
Access gating is Okta's job, not this module's. Only accounts assigned to this app
integration in Okta ever complete the flow at all, so there is no required-group or
claim check layered on top here — see D15 and wave-10.md for why that is a deliberate
difference from D13's design, not an oversight.
Unconfigured is a first-class state, same discipline as the LDAPS module this replaces:
with any of the four settings below missing, `is_configured()` is False and `describe()`
says so in the startup log, rather than the app discovering it later at the login button.
"""
import logging
import os
from typing import Optional
log = logging.getLogger("wpsuite.okta")
try:
from authlib.integrations.starlette_client import OAuth
HAVE_AUTHLIB = True
except ImportError: # pragma: no cover
HAVE_AUTHLIB = False
OAuth = None # type: ignore[assignment]
# ── configuration ─────────────────────────────────────────────────────────────
# The Okta *authorization server* issuer, e.g. https://primecontrols.okta.com/oauth2/default
# or a custom authorization server URL. Authlib discovers the rest (authorize/token/
# jwks endpoints) from `<issuer>/.well-known/openid-configuration` — nothing below is
# hand-entered except this base URL, the client credentials, and our own callback.
ISSUER = os.getenv("OKTA_ISSUER", "")
CLIENT_ID = os.getenv("OKTA_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("OKTA_CLIENT_SECRET", "")
# Must exactly match a Sign-in redirect URI registered on the Okta app integration.
# e.g. https://wp.controls.dev/api/auth/okta/callback
REDIRECT_URI = os.getenv("OKTA_REDIRECT_URI", "")
# Standard OIDC identity scopes only. No group/role scopes: T10.2's note above explains
# why access gating and permissions both stay out of the token.
SCOPES = "openid profile email"
# Which claim in the ID token carries this person's AD sAMAccountName equivalent, for
# matching against the local `users` table (T10.3). Not yet confirmed by security —
# `preferred_username` is Okta's usual default for an AD-imported user, used here as a
# documented placeholder, NOT a verified answer. Override via env once security replies
# so the real value can drop in without a code change.
IDENTITY_CLAIM = os.getenv("OKTA_IDENTITY_CLAIM", "preferred_username")
def is_configured() -> bool:
"""Whether an OIDC flow could even be attempted. Deliberately does not touch the
network — that would be a `selftest()`, added when T10.2 needs one."""
return bool(HAVE_AUTHLIB and ISSUER and CLIENT_ID and CLIENT_SECRET and REDIRECT_URI)
def describe() -> str:
"""One line for the startup log, matching the LDAPS module's discipline: an
unconfigured deploy must be visible in `docker compose logs api`, not discovered at
the login button."""
from . import okta_fake
if okta_fake.is_active():
return (f"*** FAKE OKTA PROVIDER ACTIVE — identities come from "
f"{okta_fake.ENV_VAR}, NOT from Okta. Tests only. ***")
if not HAVE_AUTHLIB:
return "Okta auth DISABLED — authlib is not installed. No one can sign in."
missing = [name for name, val in (
("OKTA_ISSUER", ISSUER), ("OKTA_CLIENT_ID", CLIENT_ID),
("OKTA_CLIENT_SECRET", CLIENT_SECRET), ("OKTA_REDIRECT_URI", REDIRECT_URI),
) if not val]
if missing:
return f"Okta auth DISABLED — missing {', '.join(missing)}. No one can sign in."
return (f"Okta auth enabled — issuer {ISSUER}, redirect {REDIRECT_URI}, "
f"identity claim {IDENTITY_CLAIM!r}")
def _build_oauth():
"""Register the Okta client. Returns None when unconfigured so the caller (T10.2's
routes) can fail loudly instead of Authlib raising deep inside a request.
Checked before `is_configured()`: the fake (T10.7) needs none of the four real
Okta settings, and must win whenever it is legitimately active so a test run
never has to also fill in placeholder OKTA_ISSUER/CLIENT_ID/etc. `is_active()`
already refuses outside a SQLite-backed test database — see okta_fake.py."""
from . import okta_fake
if okta_fake.is_active():
log.warning("*** FAKE OKTA PROVIDER ACTIVE (%s) — tests only ***", okta_fake.ENV_VAR)
return okta_fake.build()
if not is_configured():
return None
oauth = OAuth()
oauth.register(
name="okta",
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
server_metadata_url=f"{ISSUER.rstrip('/')}/.well-known/openid-configuration",
client_kwargs={"scope": SCOPES},
)
return oauth
# Built once at import time, same as `SECRET_KEY` in auth.py — a missing/bad config is a
# deploy problem to catch at startup via `describe()`, not a per-request surprise.
oauth = _build_oauth()

View File

@@ -1,190 +0,0 @@
"""A fake Okta, for tests only — T10.7.
`server/okta_auth.py` normally hands the browser off to a real Okta authorize
endpoint and exchanges the code with Okta's token endpoint over the network
(Authlib discovers both from `<issuer>/.well-known/openid-configuration`). Tests
cannot reach any of that: there is no live Okta tenant in CI, 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 — same
discipline as `server/ldap_fake.py`, D13/T10.7's LDAP predecessor.
Set `WP_OKTA_FAKE_DIRECTORY` to a JSON object and this module stands in for the
whole round trip — the authorize redirect, a stand-in "sign in at Okta" screen,
and the token exchange — with no network call anywhere:
{"root": {"email": "root@example.test", "name": "Root Person"}}
The key is the identity value a real Okta ID token would carry in whichever
claim `OKTA_IDENTITY_CLAIM` names (default `preferred_username`) — the fake
reads `okta_auth.IDENTITY_CLAIM` at request time, so it exercises whatever claim
name is actually configured rather than a hard-coded one.
WHAT THIS DOES AND DOES NOT REPLACE
Only the OAuth-protocol plumbing that talks to Okta over the network is faked —
`authorize_redirect` and `authorize_access_token`, both owned by Authlib, a
third-party library, not this app's security logic. Everything this app itself
decides stays real and untouched in `app.py`'s `okta_login()`/`okta_callback()`:
the `?next=` open-redirect guard (`_safe_next_path`), the disabled-account
check, JIT provisioning, and which claim carries identity. A fake run exercises
the actual code for all of that, not a re-implementation of it — the same
boundary `ldap_fake.py` drew around the anonymous-bind guard.
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
An environment variable that lets anyone "sign in" as any identity by visiting a
picker page is exactly the kind of thing that must never be reachable outside a
test process — D16 leaves no local password fallback and no break-glass, so a
fake provider silently active in production would be a total authentication
bypass with a friendlier UI than most. `is_active()` refuses whenever a real
database is configured, using the same test `auth._load_secret` and
`ldap_fake.is_active()` already use: a non-SQLite `DATABASE_URL` means
production, full stop. `okta_auth.describe()` also shouts when the fake is
live, and `app.py` registers the picker/consent routes only when the fake is
active at import time — in production they do not exist, not merely refuse.
"""
import json
import logging
import os
import secrets
import time
from html import escape
from typing import Optional
from urllib.parse import quote
from starlette.responses import RedirectResponse
log = logging.getLogger("wpsuite.okta.fake")
ENV_VAR = "WP_OKTA_FAKE_DIRECTORY"
# One-time authorization codes, in-process only. The login and the callback that
# redeems the code both happen inside the SAME uvicorn process within one test
# run, so this needs no more durability than that — the server restart every
# check does between runs clears it for free. Not a cache: entries are popped on
# first use (below) and expire on their own otherwise.
_CODE_TTL_SECONDS = 120
_PENDING_CODES: dict = {}
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 okta_auth (and app.py), 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 Okta provider — this looks like production, and D16 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 auth-shaped
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
return {}
def new_code(claims: dict) -> str:
code = secrets.token_urlsafe(24)
_PENDING_CODES[code] = {"claims": claims, "expires": time.time() + _CODE_TTL_SECONDS}
return code
def consume_code(code: str) -> Optional[dict]:
"""Pop and return the claims for a code, or None if unknown/expired/reused.
Popping makes the code single-use, matching a real authorization code."""
entry = _PENDING_CODES.pop(code, None)
if not entry or entry["expires"] < time.time():
return None
return entry["claims"]
def picker_page(state: str, redirect_uri: str) -> str:
"""The fake's stand-in for Okta's own sign-in screen — a plain list of the
identities `WP_OKTA_FAKE_DIRECTORY` defines, so a browser check can click
through a real page rather than skip the round trip with a minted cookie.
Deliberately plain: nothing here is styled to resemble a real Okta page."""
from . import okta_auth
rows = []
for username in directory():
href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
f"&redirect_uri={quote(redirect_uri, safe='')}&username={quote(username)}")
rows.append(
f'<li><a id="okta-fake-identity-{escape(username)}" href="{escape(href, quote=True)}">'
f'Continue as {escape(username)}</a></li>')
deny_href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
f"&redirect_uri={quote(redirect_uri, safe='')}&deny=1")
deny_href = escape(deny_href, quote=True)
return (
"<!doctype html><title>FAKE Okta — tests only</title>"
"<h1>*** FAKE OKTA PROVIDER — TESTS ONLY ***</h1>"
f"<p>Identity claim in use: <code>{escape(okta_auth.IDENTITY_CLAIM)}</code></p>"
"<ul>" + "".join(rows) + "</ul>"
f'<p><a id="okta-fake-deny" href="{deny_href}">Deny access</a></p>'
)
class _FakeOktaClient:
"""Stands in for Authlib's `oauth.okta` — the same two methods `app.py`
calls, the same async signatures, zero network calls."""
async def authorize_redirect(self, request, redirect_uri):
state = secrets.token_urlsafe(24)
# The only session write this fake makes. authorize_access_token below is
# the only read — mirrors exactly what real Authlib does with `state`,
# which is what T10.2's missing-SessionMiddleware bug was about: this
# round trip is a genuine test of the same plumbing.
request.session["_okta_fake_state"] = state
target = redirect_uri or "/api/auth/okta/callback"
url = (f"/api/auth/okta/_fake_provider?state={quote(state)}"
f"&redirect_uri={quote(target, safe='')}")
return RedirectResponse(url=url, status_code=302)
async def authorize_access_token(self, request):
from authlib.integrations.base_client import OAuthError
expected = request.session.pop("_okta_fake_state", None)
given = request.query_params.get("state", "")
if not expected or given != expected:
raise OAuthError(
error="invalid_state",
description="fake Okta: state did not match the session (T10.7 seam)")
err = request.query_params.get("error")
if err:
raise OAuthError(
error=err,
description=request.query_params.get("error_description", "denied"))
code = request.query_params.get("code", "")
claims = consume_code(code)
if claims is None:
raise OAuthError(error="invalid_grant",
description="fake Okta: unknown or expired code")
return {"userinfo": claims}
class FakeOAuth:
"""Stands in for Authlib's `OAuth()` registry. The real one exposes each
registered client as an attribute by name; `app.py` only ever touches
`.okta`, so that is the only attribute this needs."""
def __init__(self):
self.okta = _FakeOktaClient()
def build() -> "FakeOAuth":
return FakeOAuth()

View File

@@ -17,9 +17,10 @@ pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server # MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
pydantic==2.13.4 pydantic==2.13.4
python-dotenv==1.2.2 python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing
PyJWT==2.13.0 # signed session tokens 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) starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
Authlib==1.7.2 # Okta OIDC authorization-code flow (T10.1, wave 10 / D15)
httpx==0.28.1 # Authlib's OIDC client needs an HTTP client; explicit, not transitive
itsdangerous==2.2.0 # signs the OAuth-state cookie SessionMiddleware sets — required by
# Authlib's authorize_redirect/authorize_access_token, not optional

View File

@@ -9,17 +9,15 @@ and to have data to inspect.
Every /api/ route except /api/health requires a session, so this signs in first and Every /api/ route except /api/health requires a session, so this signs in first and
keeps the session cookie for the rest of the run — the same way server/smoketest.py keeps the session cookie for the rest of the run — the same way server/smoketest.py
does, reusing its opener AND its session-minting (not a second implementation). does, reusing its opener rather than growing a second implementation of it.
Credentials come from the environment so the password never has to appear in a
There is no local password anymore (D15/D16, T10.4) — see smoketest.py's own command line or shell history:
AUTHENTICATION section for why and what that means: this needs to run where it can
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under test,
and the account must already exist (this seeds a project, not a user).
export WP_SEED_USER=<admin-account> # or WP_SMOKE_USER, which is reused export WP_SEED_USER=<admin-account> # or WP_SMOKE_USER, which is reused
export WP_SEED_PASSWORD='' # or WP_SMOKE_PASSWORD
…or pass --user. Use an admin account: seeding creates a project, and --clean …or pass --user / --password. Use an admin account: seeding creates a project, and
deletes one, which needs Project Admin on it. --clean deletes one, which needs Project Admin on it.
USAGE USAGE
python3 server/seed_demo.py https://wp-suite.company.local --insecure python3 server/seed_demo.py https://wp-suite.company.local --insecure
@@ -50,19 +48,16 @@ import urllib.error
import urllib.request import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# So `from server import auth, models` / `from server.db import SessionLocal` also
# resolve (needed to mint a session — see AUTHENTICATION above).
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# The session handling is smoketest.py's, imported rather than copied: one cookie # The session handling is smoketest.py's, imported rather than copied: one cookie
# jar implementation, one session-minting flow, one place to fix. Importing is # jar implementation, one login flow, one place to fix. Importing is safe — that
# safe — that module does its work under `if __name__ == "__main__"`. # module does its work under `if __name__ == "__main__"`.
from smoketest import build_opener, seed_session_cookie # noqa: E402 from smoketest import build_opener # noqa: E402
BASE = "" BASE = ""
CTX = None CTX = None
# Carries the cookie jar holding the minted session (see AUTHENTICATION above). # Carries the cookie jar holding the session issued by /api/auth/login. This
# This script used to call urllib.request.urlopen() directly, which has no cookie # script used to call urllib.request.urlopen() directly, which has no cookie
# support, so the session was dropped and every data route answered 401 (S13). # support, so the session was dropped and every data route answered 401 (S13).
OPENER = None OPENER = None
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
@@ -121,22 +116,29 @@ def main():
ap.add_argument("--insecure", action="store_true", help="skip TLS verification") ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit") ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""), ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""),
help="existing account to sign in as (default: $WP_SEED_USER, then " help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). "
"$WP_SMOKE_USER). Use an admin account.") "Use an admin account.")
ap.add_argument("--password",
default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""),
help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — "
"preferred, so it stays out of shell history)")
args = ap.parse_args() args = ap.parse_args()
BASE = args.base_url.rstrip("/") BASE = args.base_url.rstrip("/")
if args.insecure: if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
OPENER = build_opener(CTX) OPENER = build_opener(CTX)
if not args.user: if not args.user or not args.password:
missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user),
("WP_SEED_PASSWORD", args.password)) if not v)
return abort( return abort(
"no account — $WP_SEED_USER not set.", f"no credentials — {missing} not set.",
" Every /api/ route except /api/health needs a session, so there is nothing\n" " Every /api/ route except /api/health needs a session, so there is nothing\n"
" this can seed without one. Set it and re-run:\n\n" " this can seed without one. Set them and re-run:\n\n"
" export WP_SEED_USER=<admin-account>\n\n" " export WP_SEED_USER=<admin-account>\n"
" Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n" " export WP_SEED_PASSWORD=''\n\n"
" this and smoketest.py.") " Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n"
" too, so one set of credentials serves this and smoketest.py.")
# health gate # health gate
try: try:
@@ -146,25 +148,18 @@ def main():
if st != 200: if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1 print(f"ABORT: /api/health returned {st}"); return 1
# "Sign in" — mint a session directly (see AUTHENTICATION above) and seed it # Sign in. The cookie the response sets is held by OPENER's jar and rides every
# into OPENER's jar, so it rides every request after this one. # request after this one.
try: st, body = call("POST", "/api/auth/login",
from server import auth as srv_auth {"username": args.user, "password": args.password})
from server.db import SessionLocal if st != 200:
except ImportError as e: detail = body.get("detail") if isinstance(body, dict) else body
return abort(f"cannot import the server package to mint a session: {e}", hint = (" The account may be locked: the API locks an account for a while after a\n"
" This needs to run where server/ is importable and AUTH_SECRET_KEY /\n" " few consecutive failures, so retrying with the wrong password makes this\n"
" DATABASE_URL match the target server's — see AUTHENTICATION above.") " worse. Check the password, then wait out the lockout window."
with SessionLocal() as db: if st in (401, 403, 423, 429) else
user = srv_auth.find_user(db, args.user) " Unexpected status from the login endpoint — check the API logs.")
if not user: return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
return abort(f"no account named '{args.user}'.",
" This signs in as an existing account, it doesn't create one — sign in\n"
" through Okta once first, or create it from the admin console.")
if not user.is_active:
return abort(f"'{args.user}' is disabled.", "")
token = srv_auth.create_token(user)
seed_session_cookie(token, BASE)
logged_in = True logged_in = True
print(f"Signed in as {args.user}.") print(f"Signed in as {args.user}.")

View File

@@ -6,23 +6,16 @@ NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq. release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
AUTHENTICATION AUTHENTICATION
There is no local password anymore (D15/D16, T10.4) — identity is Okta's job, Every /api/ route except /api/health requires a session (auth_gate in
and Okta requires a real browser to complete, which this stdlib script cannot server/app.py), so the script signs in first and keeps the session cookie for
do. So instead of signing in over HTTP the way the front end does, this script the rest of the run. Credentials come from the environment by preference, so a
mints a session the same way server/app.py's okta_callback() does after Okta password never has to appear in a command line or shell history:
hands back an identity: auth.create_token() for an existing account, seeded
straight into the cookie jar. That means it needs to run somewhere that can
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under
test — inside the api container, or locally against your dev DB. It can no
longer sign in to an arbitrary remote URL from an unrelated workstation; if
the target is remote, run it on that host or inside that container instead.
export WP_SMOKE_USER=smoketest export WP_SMOKE_USER=smoketest
export WP_SMOKE_PASSWORD=''
python3 server/smoketest.py https://wp-suite.company.local python3 server/smoketest.py https://wp-suite.company.local
…or pass --user explicitly. The account must already exist — sign it in …or pass --user / --password explicitly.
through Okta once first (or create it from the admin console) if it doesn't;
this script promotes no one and provisions nothing.
Use an ADMIN account. The script creates a project and deletes it again at the Use an ADMIN account. The script creates a project and deletes it again at the
end, and deleting one takes Project Admin on that project (require_project_admin); end, and deleting one takes Project Admin on that project (require_project_admin);
@@ -31,29 +24,26 @@ AUTHENTICATION
discover it in the cleanup step. discover it in the cleanup step.
USAGE USAGE
# From inside the api container (has AUTH_SECRET_KEY and DATABASE_URL; hits # Against the deployed site (through the NGINX proxy):
# FastAPI directly): python3 server/smoketest.py https://wp-suite.company.local
docker compose exec -e WP_SMOKE_USER api \
python /app/server/smoketest.py http://localhost:8000
# Local dev, against the app you're running yourself: # Self-signed / internal TLS cert? skip verification:
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=smoketest
python3 server/smoketest.py http://localhost:8000
# Self-signed / internal TLS cert on the HTTP side? skip verification:
python3 server/smoketest.py https://wp-suite.company.local --insecure python3 server/smoketest.py https://wp-suite.company.local --insecure
# From inside the api container (hits FastAPI directly):
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
python /app/server/smoketest.py http://localhost:8000
# Leave the demo project in the database so you can open it in the UI: # Leave the demo project in the database so you can open it in the UI:
python3 server/smoketest.py http://localhost:8000 --keep python3 server/smoketest.py https://wp-suite.company.local --keep
The base URL is the SITE root (no /api). Default: http://localhost:8000 The base URL is the SITE root (no /api). Default: http://localhost:8000
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
could not start (unreachable host, missing credentials, or no account by that could not start (unreachable host, missing or rejected credentials). 2 is kept
username). 2 is kept distinct on purpose: "I could not test this" is not the distinct on purpose: "I could not test this" is not the same answer as "this is
same answer as "this is broken", and conflating them is what made an broken", and conflating them is what made an unauthenticated version of this
unauthenticated version of this script report a wall of failures against a script report a wall of failures against a perfectly healthy stack.
perfectly healthy stack.
""" """
import argparse import argparse
import http.cookiejar import http.cookiejar
@@ -63,12 +53,6 @@ import ssl
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
from urllib.parse import urlparse
# So `from server import auth, models` / `from server.db import SessionLocal` resolve
# when this file is run directly (`python3 server/smoketest.py`) rather than as
# `python -m server.smoketest` — same reasoning as the sys.path lines in tests/*.py.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ── tiny colored reporter ───────────────────────────────────────────────────── # ── tiny colored reporter ─────────────────────────────────────────────────────
_PASS, _FAIL = [], [] _PASS, _FAIL = [], []
@@ -82,40 +66,19 @@ def check(name, cond, detail=""):
BASE = "" BASE = ""
CTX = None CTX = None
# One opener for the whole run, carrying the cookie jar that holds the session. # One opener for the whole run, carrying the cookie jar that holds the session
# urlopen() has no cookie support, which is why the session used to be dropped on # issued by /api/auth/login. urlopen() has no cookie support, which is why the
# the floor and every data route answered 401. # session used to be dropped on the floor and every data route answered 401.
OPENER = None OPENER = None
COOKIE_JAR = None
def build_opener(ctx=None): def build_opener(ctx=None):
global COOKIE_JAR handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())]
COOKIE_JAR = http.cookiejar.CookieJar()
handlers = [urllib.request.HTTPCookieProcessor(COOKIE_JAR)]
if ctx is not None: if ctx is not None:
handlers.append(urllib.request.HTTPSHandler(context=ctx)) handlers.append(urllib.request.HTTPSHandler(context=ctx))
return urllib.request.build_opener(*handlers) return urllib.request.build_opener(*handlers)
def seed_session_cookie(token: str, base: str) -> None:
"""Put a minted session into the jar directly, the same shape a Set-Cookie
response from the old /api/auth/login would have produced — so the logout
check below (which relies on the jar honoring logout()'s Set-Cookie that
expires it) keeps working unchanged. `base` is explicit rather than read off
this module's own BASE global, so seed_demo.py (which imports this function
but has its own BASE) seeds the cookie for the host it's actually targeting."""
host = urlparse(base).hostname or "localhost"
COOKIE_JAR.set_cookie(http.cookiejar.Cookie(
version=0, name="wp_session", value=token,
port=None, port_specified=False,
domain=host, domain_specified=True, domain_initial_dot=False,
path="/", path_specified=True,
secure=False, expires=None, discard=True,
comment=None, comment_url=None, rest={"HttpOnly": None},
))
def call(method, path, body=None): def call(method, path, body=None):
"""Returns (status_code, parsed_body). Never raises on HTTP status.""" """Returns (status_code, parsed_body). Never raises on HTTP status."""
url = BASE + path url = BASE + path
@@ -153,7 +116,10 @@ def main():
ap.add_argument("--insecure", action="store_true", help="skip TLS verification") ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)") ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""), ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
help="existing account to sign in as (default: $WP_SMOKE_USER). Use an admin account.") help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
"so it stays out of shell history)")
args = ap.parse_args() args = ap.parse_args()
BASE = args.base_url.rstrip("/") BASE = args.base_url.rstrip("/")
if args.insecure: if args.insecure:
@@ -162,14 +128,17 @@ def main():
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n") print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
# Refuse to start without a username rather than running headlong into 401s. # Refuse to start without credentials rather than running headlong into 401s.
if not args.user: if not args.user or not args.password:
missing = " and ".join(
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
return abort( return abort(
"no account — $WP_SMOKE_USER not set.", f"no credentials — {missing} not set.",
" Every /api/ route except /api/health needs a session, so there is nothing\n" " Every /api/ route except /api/health needs a session, so there is nothing\n"
" meaningful to test without one. Set it and re-run:\n\n" " meaningful to test without one. Set them and re-run:\n\n"
" export WP_SMOKE_USER=<admin-account>\n\n" " export WP_SMOKE_USER=<admin-account>\n"
" Or pass --user. Use an admin account: the run creates a project\n" " export WP_SMOKE_PASSWORD=''\n\n"
" Or pass --user/--password. Use an admin account: the run creates a project\n"
" and deletes it again, and the delete needs Project Admin on it.") " and deletes it again, and the delete needs Project Admin on it.")
project_id = None project_id = None
@@ -188,29 +157,21 @@ def main():
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True, check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
f"status={st} body={body}") f"status={st} body={body}")
# 2) "Sign in" — mint a session directly (see AUTHENTICATION above) and seed # 2) Sign in. The cookie the response sets is held by OPENER's jar and rides
# it into OPENER's jar, so it rides every request after this one exactly the # every request after this one.
# way a real Set-Cookie response would have. st, body = call("POST", "/api/auth/login",
try: {"username": args.user, "password": args.password})
from server import auth as srv_auth if st != 200:
from server.db import SessionLocal detail = body.get("detail") if isinstance(body, dict) else body
except ImportError as e: hint = (" The account may be locked: the API locks an account for a while after\n"
return abort(f"cannot import the server package to mint a session: {e}", " a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n"
" This script now needs to run where server/ is importable and\n" " so re-running with the wrong password makes this worse, not better.\n"
" AUTH_SECRET_KEY / DATABASE_URL match the target server's — see\n" " Check the password, then wait out the lockout window."
" AUTHENTICATION above.") if st in (401, 403, 423, 429) else
with SessionLocal() as db: " Unexpected status from the login endpoint — check the API logs.")
user = srv_auth.find_user(db, args.user) return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
if not user:
return abort(f"no account named '{args.user}'.",
" This script signs in as an existing account, it doesn't create one —\n"
" sign in through Okta once first, or create it from the admin console.")
if not user.is_active:
return abort(f"'{args.user}' is disabled.", "")
token = srv_auth.create_token(user)
seed_session_cookie(token, BASE)
logged_in = True logged_in = True
check("session cookie seeded", bool(token)) check("login issues a session", st == 200)
# 3) Prove the session actually travels — this is the check whose absence let # 3) Prove the session actually travels — this is the check whose absence let
# an unauthenticated version of this script look like a broken stack. # an unauthenticated version of this script look like a broken stack.

View File

@@ -40,6 +40,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
PW = "CorrectHorseBattery9"
_PASS, _FAIL = [], [] _PASS, _FAIL = [], []
@@ -150,29 +151,33 @@ def seed(db_path):
for u in db.query(models.User).all()} for u in db.query(models.User).all()}
# T10.7. The app authenticates through Okta, which no test can reach, and # D13 / T10.7. The app authenticates by binding to a domain controller, which no
# start_server launches it as a SUBPROCESS — so a monkeypatch here would never # test can reach, and start_server launches it as a SUBPROCESS — so a monkeypatch
# reach the code doing the authenticating. server/okta_fake.py reads this # here would never reach the code doing the authenticating. server/ldap_fake.py
# instead, and refuses to work against a non-SQLite database. # reads this instead, and refuses to work against a non-SQLite database.
# #
# Almost no check ever signs in (seed() mints tokens with auth.create_token and # Most checks never sign in (seed() mints tokens with auth.create_token and sets
# sets the cookie directly), so this matters only where the sign-in ROUND TRIP # the cookie directly), so this matters only where the login FORM is driven —
# is driven — url_state_check's deep-link case. It is set for every server here # 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 # anyway so that a test which starts signing in later does not fail mysteriously.
# mysteriously — the same reasoning the LDAP predecessor (D13/T10.7) used.
FAKE_DIRECTORY = json.dumps({ FAKE_DIRECTORY = json.dumps({
u: {"email": f"{u}@example.test", "name": u.title()} 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") for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
}) })
def start_server(port, db_path, extra_env=None): def start_server(port, db_path):
env = dict(os.environ) env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/") env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production") env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
env["WP_OKTA_FAKE_DIRECTORY"] = FAKE_DIRECTORY env["WP_LDAP_FAKE_DIRECTORY"] = FAKE_DIRECTORY
if extra_env: # SET empty, never pop: server/db.py calls load_dotenv() at import and
env.update(extra_env) # 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( proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1", [sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"], "--port", str(port), "--log-level", "warning"],
@@ -455,10 +460,7 @@ def main():
finally: finally:
if args.keep_server: if args.keep_server:
print(f"\n --keep-server: still up at {base}, database at {db_path}") print(f"\n --keep-server: still up at {base}, database at {db_path}")
# No local password exists (D15/D16) — there's nothing to type into a login print(" Sign in as root / " + PW)
# form. Set the session cookie directly, the same way this script's own
# fixture does, from the browser console on that origin:
print(f" document.cookie = 'wp_session={tok['root']}; path=/'")
else: else:
if server: if server:
# Wait for it to actually exit before deleting the database out from # Wait for it to actually exit before deleting the database out from

View File

@@ -8,17 +8,9 @@ They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared,
self-injecting component (guarded so the creator's inline copy still wins on self-injecting component (guarded so the creator's inline copy still wins on
its own page). its own page).
Static half greps the counts; browser half drives the users console's dialogs Static half greps the counts; browser half drives the password-reset prompt on
with natives poisoned and proves they still work end to end. the users console with natives poisoned, and proves validate() answers AT the
input while the server round-trip completes end to end.
Used to drive this via the password-reset prompt specifically, because it was
the one place on this page exercising wp-dialog.js's PROMPT variant (text input
+ client-side validate()) rather than its confirm variant. T10.4 (D15/D16)
removed admin password reset entirely — there is no password to reset anymore
— so that coverage moved with it. The prompt-with-validate() pattern itself is
still exercised, just not on this page: see creator_dialogs_check.py for
wp-creation-app.js's own wpPromptDialog() call sites. If users.html ever grows
a new prompt-style dialog, it belongs back in this file.
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone. Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
Exit 0 all passed, 1 a failure, 2 could not run. Exit 0 all passed, 1 a failure, 2 could not run.

View File

@@ -31,7 +31,7 @@ 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__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
STUB = """ STUB = """
window.__dialogs = []; window.__dialogs = [];

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

@@ -323,13 +323,6 @@ def run(page, base, tok):
{"text": "Probe Building One,Probe Level 1,Probe Sector B"}) {"text": "Probe Building One,Probe Level 1,Probe Sector B"})
chk("the import reports it as reactivated, not created or duplicate", chk("the import reports it as reactivated, not created or duplicate",
len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"]) len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"])
# The 2026-08-23 production 500, pinned (locations side): Postgres-refused
# values reject by line, on every dialect, never crash the request.
hz = api(page, "POST", "/api/projects/projA/locations/import",
{"text": "Probe Building One," + "Y" * 220 + ",S1", "dry_run": True})
chk("an over-long name is a line rejection, not a 500",
hz["status"] == 200 and hz["body"]["rejected"]
and "200 characters" in hz["body"]["rejected"][0]["reason"], hz["body"])
same = [n for n in api(page, "GET", same = [n for n in api(page, "GET",
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"] "/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
if n["path"] == sec["path"]] if n["path"] == sec["path"]]

View File

@@ -54,20 +54,6 @@ def main():
chk("locations and materials are both instances of it - not a copy beside it", chk("locations and materials are both instances of it - not a copy beside it",
"locList = WPListImport(" in suite and "matList = WPListImport(" in suite "locList = WPListImport(" in suite and "matList = WPListImport(" in suite
and "function locImport(dryRun){ locList.importText" 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() model = open(os.path.join(ROOT, "server", "models.py"), encoding="utf-8").read()
mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")] mat_block = model[model.find("class MaterialItem"):model.find("class WpFile")]
cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M) cols = re.findall(r"^\s+(\w+): Mapped", mat_block, re.M)
@@ -105,20 +91,6 @@ def main():
_, listing = api(base, "/api/projects/projA/materials", root) _, listing = api(base, "/api/projects/projA/materials", root)
chk("...and nothing was written", listing["items"] == []) chk("...and nothing was written", listing["items"] == [])
# The 2026-08-23 production 500, pinned: what Postgres refuses (VARCHAR
# overflow, control bytes) must come back as a per-line rejection - on
# EVERY dialect - never crash the request.
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
{"text": "Sample " + "x" * 300 + ",EA", "dry_run": True})
chk("an over-long description is a line rejection, not a 500",
code == 200 and rep["rejected"] and "300 characters" in rep["rejected"][0]["reason"]
and not rep["created"], ascii_(rep))
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
{"text": "Sample widget\u0000,EA", "dry_run": True})
chk("a control byte is a line rejection, not a 500",
code == 200 and rep["rejected"]
and "control characters" in rep["rejected"][0]["reason"], ascii_(rep))
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST", code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
{"text": text, "dry_run": False}) {"text": text, "dry_run": False})
_, listing = api(base, "/api/projects/projA/materials", root) _, listing = api(base, "/api/projects/projA/materials", root)

View File

@@ -1,327 +0,0 @@
#!/usr/bin/env python3
"""Does Okta sign-in hold its guarantees? — D15/D16 / T10.7.
The Okta equivalent of tests/ldap_auth_check.py (D13's predecessor). Covers the
things that would still let the app *look* fine while quietly failing:
1. The fake provider must be impossible to select against a real database.
2. A one-time authorization code cannot be redeemed twice (replay).
3. An unsolicited hit on the callback (no real login ever started) is refused,
not a 500 — and creates no account.
4. A denied ("Cancel") consent is refused cleanly (?error=cancelled) and
creates no account.
5. An unknown fake identity at consent is refused and creates no account.
6. A correct sign-in works, and ?next= carries through to the real target —
but only when it is a same-site path; an off-site next= is ignored.
7. A disabled local account is refused even though Okta itself approved it —
deprovisioning stays local (D15).
8. An unrecognized identity is JIT-provisioned at the lowest role.
9. An existing admin signs in and is STILL an admin, with their locally-set
name intact — Okta never overwrites what this app already knows.
10. OKTA_IDENTITY_CLAIM is genuinely configurable: sign-in still works with a
non-default claim name, proving T10.3's "no hard-coded claim" promise.
Self-contained: throwaway SQLite + its own uvicorn. No browser, no live Okta —
server/okta_fake.py stands in for the provider. Two layers, like its LDAP
predecessor: guards that need no server (direct calls into okta_fake), then
sign-in checks against a real running app.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from http.cookiejar import CookieJar
from urllib.parse import quote, urlparse
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 = [], []
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 users_in(db_path):
"""Read the users table straight out of the given file. Deliberately NOT
via server.db.SessionLocal — that engine binds from DATABASE_URL at import,
so setting the env var later keeps reading whichever file came first. The
LDAP predecessor lost two assertions to exactly this before it was noticed."""
import sqlite3
con = sqlite3.connect(db_path)
try:
try:
return {r[0]: (r[1], r[2], bool(r[3]))
for r in con.execute(
"select username, role, full_name, is_active from users")}
except sqlite3.OperationalError:
return {}
finally:
con.close()
def start(port, db_path, fake, identity_claim=None):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env["AUTH_SECRET_KEY"] = "okta-auth-check-not-for-production"
env["WP_OKTA_FAKE_DIRECTORY"] = json.dumps(fake)
if identity_claim:
env["OKTA_IDENTITY_CLAIM"] = identity_claim
else:
env.pop("OKTA_IDENTITY_CLAIM", None)
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 opener():
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(CookieJar()))
def fetch(op, url):
"""GET, auto-following redirects (urllib's default) — matches what a real
browser does across the login -> fake-provider -> callback -> target chain.
Returns (final_url, status, body)."""
try:
with op.open(url, timeout=10) as r:
return r.geturl(), r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.geturl(), e.code, e.read().decode("utf-8", "replace")
def sign_in(base, op, username, next_path=None):
"""Drive the real round trip: /api/auth/okta/login -> the fake provider's
picker page -> consent as `username` -> okta_callback(). Every hop except
the fake provider itself is the app's own unmodified code."""
login_url = base + "/api/auth/okta/login"
if next_path:
login_url += "?next=" + quote(next_path, safe="")
final_url, status, body = fetch(op, login_url)
if "_fake_provider" not in final_url:
return final_url, status, body # never reached the fake at all
m = re.search(
r'id="okta-fake-identity-%s" href="([^"]+)"' % re.escape(username), body)
if not m:
return final_url, status, body # identity not offered
consent_url = base + m.group(1).replace("&amp;", "&")
return fetch(op, consent_url)
def deny(base, op):
_, _, body = fetch(op, base + "/api/auth/okta/login")
m = re.search(r'id="okta-fake-deny" href="([^"]+)"', body)
if not m:
return None, None, body
return fetch(op, base + m.group(1).replace("&amp;", "&"))
def consent_as(base, op, username, state_override=None):
"""Reach the consent endpoint directly with an arbitrary `username` (which
need not be one the picker actually offered) and, optionally, a `state`
that does not match the one the login step stashed in the session — so
'unknown identity' and 'tampered state' can be tested as the server's own
refusal, not merely as absence from the picker's list."""
_, _, body = fetch(op, base + "/api/auth/okta/login")
m = re.search(r'id="okta-fake-deny" href="([^"]+)"', body)
if not m:
return None, None, body
href = m.group(1).replace("&amp;", "&").replace("deny=1", "username=" + quote(username))
if state_override is not None:
href = re.sub(r"state=[^&]*", "state=" + quote(state_override), href)
return fetch(op, base + href)
def main():
print("1. the guards that do not need a server")
os.environ["DATABASE_URL"] = "sqlite:///./_oktacheck_unit.db"
os.environ["WP_OKTA_FAKE_DIRECTORY"] = json.dumps({"root": {"email": "r@x.test", "name": "R"}})
from server import okta_fake
chk("the fake is active against a SQLite database", okta_fake.is_active())
out = subprocess.run(
[sys.executable, "-c",
"from server import okta_fake; print(okta_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_OKTA_FAKE_DIRECTORY": json.dumps({"root": {"email": "r@x.test"}})})
chk("the fake provider REFUSES to work against a non-SQLite database",
out.stdout.strip() == "False", out.stdout.strip() or out.stderr[-200:])
code = okta_fake.new_code({"preferred_username": "root"})
first = okta_fake.consume_code(code)
second = okta_fake.consume_code(code)
chk("a fresh code redeems once", first == {"preferred_username": "root"}, first)
chk("...and a REPLAYED code is refused the second time", second is None, second)
print("\n2. sign-in, against a server")
db_fd, db_path = tempfile.mkstemp(suffix=".db"); os.close(db_fd)
port = free_port()
fake = {"root": {"email": "root@example.test", "name": "Root Person"},
"newperson": {"email": "newperson@example.test", "name": "New Person"}}
server = start(port, db_path, fake)
if server is None:
print("the test server would not start.")
return 2
base = f"http://127.0.0.1:{port}"
try:
# Seed one pre-existing admin and one pre-existing but disabled account,
# the same way manage_users.py / the admin console would have left them.
import sqlalchemy as _sa
from server.db import Base
from server import models # noqa: F401
eng = _sa.create_engine("sqlite:///" + db_path.replace("\\", "/"))
Base.metadata.create_all(bind=eng)
with eng.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'))"))
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_shelved','shelved','','Shelved Person','project_user',0,0,0,"
"'','','',0,'',datetime('now'),datetime('now'))"))
eng.dispose()
fake["shelved"] = {"email": "shelved@example.test", "name": "Shelved Person"}
# Restart so the running process picks up the augmented directory.
server.kill(); server.wait(timeout=10)
server = start(port, db_path, fake)
if server is None:
print("the test server would not restart with the augmented directory.")
return 2
final_url, status, _ = sign_in(base, opener(), "root")
chk("a seeded identity signs in", status == 200 and urlparse(final_url).path == "/index.html",
(final_url, status))
chk("...and the existing admin is STILL an admin",
users_in(db_path).get("root", ("", "", None))[0] == "admin", users_in(db_path))
chk("...with their locally-set name untouched by Okta",
users_in(db_path).get("root", (None, None))[1] == "Set By Hand", users_in(db_path))
before = set(users_in(db_path))
final_url, status, _ = sign_in(base, opener(), "newperson")
chk("an unrecognized identity is JIT-provisioned", status == 200, (final_url, status))
chk("...at the lowest role", users_in(db_path).get("newperson", ("", "", None))[0]
== "project_user", users_in(db_path).get("newperson"))
chk("...and only one new row appeared",
set(users_in(db_path)) - before == {"newperson"}, set(users_in(db_path)) - before)
final_url, status, _ = sign_in(base, opener(), "shelved")
chk("a disabled local account is refused despite Okta approving it",
"error=disabled" in final_url, final_url)
before = set(users_in(db_path))
final_url, status, _ = consent_as(base, opener(), "nobody-such-identity")
# okta_callback()'s `except OAuthError` is deliberately generic (T10.5:
# a plain-language ?error= for whatever Authlib/the provider rejected,
# not a code-by-code breakdown) — so this lands on the same ?error=
# cancelled as every other refusal, not a distinct "invalid_request".
# The thing actually under test is the SERVER-side refusal, verified by
# checking no account got created — not the display string.
chk("consenting as an identity the fake never offered is refused BY THE SERVER"
" (not just absent from the picker)",
"login.html" in final_url and "error=cancelled" in final_url, final_url)
chk("...and creates nothing", set(users_in(db_path)) == before,
set(users_in(db_path)) - before)
before = set(users_in(db_path))
final_url, status, _ = consent_as(base, opener(), "root", state_override="tampered-state")
chk("a consent hit whose state does not match the session is refused",
"login.html" in final_url and "error=cancelled" in final_url, final_url)
chk("...and creates nothing", set(users_in(db_path)) == before,
set(users_in(db_path)) - before)
before = set(users_in(db_path))
final_url, status, _ = deny(base, opener())
chk("denying consent lands back on login with a plain message",
"login.html" in final_url and "error=cancelled" in final_url, final_url)
chk("...and creates nothing", set(users_in(db_path)) == before,
set(users_in(db_path)) - before)
cold = opener()
final_url, status, _ = fetch(
cold, base + "/api/auth/okta/callback?code=forged&state=forged")
chk("an unsolicited hit on the callback (no login ever started) is refused, not a 500",
status == 200 and "login.html" in final_url and "error=cancelled" in final_url,
(final_url, status))
final_url, status, _ = sign_in(base, opener(), "root", next_path="/wp-creation-index.html?wp=x")
chk("a same-site ?next= survives the round trip",
urlparse(final_url).path == "/wp-creation-index.html"
and "wp=x" in urlparse(final_url).query, final_url)
final_url, status, _ = sign_in(base, opener(), "root", next_path="https://evil.example.com/phish")
chk("an off-site ?next= is ignored, not honoured",
urlparse(final_url).path == "/index.html", final_url)
finally:
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n3. the identity claim name is genuinely configurable (T10.3)")
db_fd2, db2 = tempfile.mkstemp(suffix=".db"); os.close(db_fd2)
port2 = free_port()
server2 = start(port2, db2, {"root": {"email": "root@example.test", "name": "Root"}},
identity_claim="upn")
if server2 is None:
print("the identity-claim test server would not start.")
return 2
try:
base2 = f"http://127.0.0.1:{port2}"
final_url, status, _ = sign_in(base2, opener(), "root")
chk("sign-in works with a non-default OKTA_IDENTITY_CLAIM (upn)",
status == 200 and urlparse(final_url).path == "/index.html", (final_url, status))
chk("...and JIT-provisioned the account under that identity",
users_in(db2).get("root", ("", "", None))[0] == "project_user", users_in(db2))
finally:
server2.kill()
try:
server2.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

@@ -33,7 +33,7 @@ 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__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \ READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \
"#pipeline-strip .pipe-error')" "#pipeline-strip .pipe-error')"

View File

@@ -47,7 +47,7 @@ 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__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
from browser_check import seed, start_server # noqa: E402 from browser_check import seed, start_server, PW # noqa: E402,F401
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html") HTML = os.path.join(ROOT, "html")

View File

@@ -7,13 +7,7 @@ the suite, so no work package had an address. This checks the promise those emai
will rest on. will rest on.
1. a URL identifying a work package opens that work package 1. a URL identifying a work package opens that work package
2. the same URL works for a SIGNED-OUT user, via the real Okta sign-in round 2. the same URL works for a SIGNED-OUT user, via login, landing on the target
trip (T10.7's fake provider stands in for Okta itself — see
server/okta_fake.py — but the app's own login.html, the redirect to
/api/auth/okta/login, the state round trip through SessionMiddleware, and
okta_callback()'s handling of ?next= are all real, unmodified code).
Landing on the requested target, not the home page, doubles as setup for
scenarios 3-6 below.
3. refresh preserves project, package, tab and view 3. refresh preserves project, package, tab and view
4. Back and Forward move through states without a reload or a broken view 4. Back and Forward move through states without a reload or a broken view
5. the URL survives being copied to a second browsing context 5. the URL survives being copied to a second browsing context
@@ -33,7 +27,7 @@ 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__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402
def settle(page, seconds=1.4): def settle(page, seconds=1.4):
@@ -174,7 +168,7 @@ def main():
finally: finally:
page2.close() page2.close()
print("\n2. the same URL works for a signed-out user, via the real Okta round trip") print("\n2. the same URL works for a signed-out user, via login")
page.clear_cookies() page.clear_cookies()
page.goto(deep) page.goto(deep)
settle(page, 1.6) settle(page, 1.6)
@@ -183,44 +177,23 @@ def main():
nxt = page.eval("new URLSearchParams(location.search).get('next')||''") nxt = page.eval("new URLSearchParams(location.search).get('next')||''")
chk("...carrying the requested target, package id and all", chk("...carrying the requested target, package id and all",
"wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt) "wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt)
page.eval("document.getElementById('username').value=%r" % "root")
# Drive the actual button, not a shortcut to it — its href already page.eval("document.getElementById('password').value=%r" % PW)
# carries ?next= (login.js's safeNext()); this is the same click a page.eval("document.querySelector('form').requestSubmit"
# person makes. "? document.querySelector('form').requestSubmit()"
signin_href = page.eval( ": document.querySelector('form').submit()")
"(document.getElementById('okta-signin')||{}).getAttribute('href')||''") for _ in range(40):
chk("the sign-in link itself carries ?next=", "next=" in signin_href, signin_href) if "wp-creation-index.html" in page.eval("location.href"):
page.goto(base + signin_href)
for _ in range(30):
if "_fake_provider" in page.eval("location.href"):
break break
time.sleep(0.3) time.sleep(0.3)
chk("the app hands off to the (fake) Okta provider", settle(page, 1.2)
"_fake_provider" in page.eval("location.href"), page.eval("location.href")) # NOT `"wp-creation-index.html" in location.href` — that string is in the
# ?next= parameter too, so the check passed while still sitting on
# The fake provider's own picker page — a real page, not a shortcut. # login.html with the sign-in rejected. Assert we actually LEFT the
# See server/okta_fake.py: only the network-touching Authlib calls are # login page (D13/T10.7: it caught nothing when the bind started failing).
# faked, not app.py's own login/callback/JIT/guard code.
identity_href = page.eval(
"(document.getElementById('okta-fake-identity-root')||{}).getAttribute('href')||''")
chk("the fake provider offers the seeded 'root' identity", bool(identity_href),
page.eval("document.body.innerHTML"))
page.goto(base + identity_href)
for _ in range(30):
href = page.eval("location.href")
if "login.html" not in href and "_fake_provider" not in href:
break
time.sleep(0.3)
settle(page, 1.0)
# NOT `"wp-creation-index.html" in location.href` alone — that string
# is in the ?next= parameter too, so this would pass while still
# sitting on login.html with the sign-in rejected (the same mistake
# D13/T10.7's LDAP predecessor caught and fixed here). Assert we
# actually LEFT the login page.
href = page.eval("location.href") href = page.eval("location.href")
chk("signing in continues to the requested page, not the home page", chk("signing in continues to the requested page, not the home page",
"login.html" not in href and "wp-creation-index.html" in href "login.html" not in href and "wp-creation-index.html" in href, href)
and "wp=wpA1" in href, href)
for _ in range(30): for _ in range(30):
if page.eval("!!window.wpCreatorReady"): if page.eval("!!window.wpCreatorReady"):
break break