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>
This commit is contained in:
2026-08-24 09:51:58 -05:00
parent 24fff382c8
commit eecd724e02
3 changed files with 128 additions and 5 deletions

View File

@@ -39,6 +39,8 @@ from cdp import free_port # noqa: E40
_PASS, _FAIL = [], []
PW = "CorrectHorseBattery9"
GROUP = "WP-Suite-Users"
SECRET = "ldap-auth-check-not-for-production"
os.environ.setdefault("AUTH_SECRET_KEY", SECRET)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -61,10 +63,31 @@ def post(base, path, payload):
return e.code, {}
def get(base, path, token):
req = urllib.request.Request(base + path, headers={"Cookie": f"wp_session={token}"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {}
def post_as(base, path, token, payload):
req = urllib.request.Request(base + path, method="POST",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json",
"Cookie": f"wp_session={token}"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, json.loads(r.read().decode() or "{}")
except urllib.error.HTTPError as e:
return e.code, {}
def start(port, db_path, fake, required_group=""):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env["AUTH_SECRET_KEY"] = "ldap-auth-check-not-for-production"
env["AUTH_SECRET_KEY"] = SECRET
env["WP_LDAP_FAKE_DIRECTORY"] = json.dumps(fake)
if required_group:
env["LDAP_REQUIRED_GROUP"] = required_group
@@ -301,6 +324,84 @@ def main():
except subprocess.TimeoutExpired:
pass
print("")
print("5. the console's own routes, and what token_version actually does")
db_fd4, db4 = tempfile.mkstemp(suffix=".db"); os.close(db_fd4)
import sqlalchemy as _sa1
from server.db import Base as _B1
from server import models as _m1, auth as _auth # noqa: F401
url4 = "sqlite:///" + db4.replace("\\", "/")
eng1 = _sa1.create_engine(url4)
_B1.metadata.create_all(bind=eng1)
with eng1.begin() as con:
for uid, un, role in [("user_boss", "root", "admin"),
("user_pat", "pat", "project_user")]:
con.execute(_sa1.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
f"('{uid}','{un}','','{un}','{role}',1,0,0,'','','',0,'',"
"datetime('now'),datetime('now'))"))
class _U:
pass
def _tok(uid, un, role):
u = _U(); u.id = uid; u.username = un; u.role = role; u.token_version = 0
return _auth.create_token(u)
boss_tok = _tok("user_boss", "root", "admin")
pat_tok = _tok("user_pat", "pat", "project_user")
port4 = free_port()
server4 = start(port4, db4, fake, required_group="")
if server4 is None:
print("the fourth test server would not start.")
return 2
b4 = f"http://127.0.0.1:{port4}"
try:
st, body = get(b4, "/api/auth/users", boss_tok)
chk("an admin can list accounts", st == 200, st)
post(b4, "/api/auth/login", {"username": "outsider", "password": PW})
st, body = get(b4, "/api/auth/users", boss_tok)
rows = body if isinstance(body, list) else (body.get("users") or body.get("items") or [])
names = [u.get("username") for u in rows if isinstance(u, dict)]
chk("a just-provisioned account appears in the Admin console list",
"outsider" in names, names)
# Exactly the request users.html sends — D13 criterion 4.
st, _ = post_as(b4, "/api/auth/users/user_pat/role", boss_tok, {"role": "admin"})
chk("granting admin through the console route succeeds", st == 200, st)
chk("...and the role really changed", users_in(db4).get("pat") == "admin", users_in(db4))
# token_version. NOTHING in app.py bumps it any more: is_active and role are
# re-read from the database every request, so both take effect at once without
# it. What it still does is invalidate an ALREADY-ISSUED cookie, which is what
# manage_users does on disable. Bump it directly and prove the effect.
eng2 = _sa1.create_engine(url4)
with eng2.begin() as con:
con.execute(_sa1.text("update users set token_version = 1 where id='user_pat'"))
eng2.dispose()
st, _ = get(b4, "/api/auth/me", pat_tok)
chk("bumping token_version invalidates a cookie already issued", st == 401, st)
st, _ = get(b4, "/api/auth/me", boss_tok)
chk("...and leaves every other session alone", st == 200, st)
st, body = post(b4, "/api/auth/login", {"username": "root", "password": "wrong"})
blob = json.dumps(body).lower()
chk("no AD sub-code leaks into a response body",
not any(c in blob for c in ("52e", "525", "532", "533", "775", "data ")), body)
from server import ldap_auth as _la
chk("...though _err49 does parse one when AD sends it",
"52e" in _la._err49({"message": "80090308: LdapErr: DSID-0C0903A9, comment: "
"AcceptSecurityContext error, data 52e, v4563"}))
finally:
eng1.dispose()
server4.kill()
try:
server4.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n" + "-" * 54)
print(f"{len(_PASS)}/{len(_PASS) + len(_FAIL)} checks passed.")
for f in _FAIL: