Files
agent-identity-provisioning/validate-all-logins.py
T
vptechops 1b8bd843d9 fix: get_totp exact-name resolution + all-agent login validator
bw's name search is fuzzy: every agent email contains "coo"
(tsgstaff-coo-*), so `bw get totp "coo Cloudron"` matched 10 items and
errored. get_totp now resolves via get_item_id (exact-name filter)
first, mirroring get_item_password.

Added validate-all-logins.py: per-agent fresh-browser-context login
(shared contexts carry session cookies and hide the login form),
asserts password+TOTP round-trip, then verifies every stored API
credential against its system. First full run: 9/10 PASS.

Known failure: vp-compliance stored password does not match the
account ("Incorrect username or password" pre-TOTP) -- enrollment
typed a different value than stored. Needs Cloudron admin reset,
then update_item and re-validate.
2026-08-14 11:48:06 -05:00

183 lines
6.0 KiB
Python

#!/usr/bin/env python3
"""
validate-all-logins.py -- End-to-end phase-1 validation for all COO agents.
For each agent in the manifest (excluding non-COO identities):
1. Fresh Cloudron login: username + password + TOTP from BW
2. Assert we land on an authenticated page
3. If the agent has Gitea/Redmine/Discourse credentials in BW, verify
each API credential against its API
Prints a PASS/FAIL matrix. Exits 1 if any login fails.
Usage:
docker compose run --rm --entrypoint python3 provision validate-all-logins.py
"""
import json
import os
import sys
import time
import urllib.request
from pathlib import Path
import yaml
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = os.environ.get("CLOUDRON_BASE", "https://my.knownelement.com")
GITEA_URL = "https://git.knownelement.com"
REDMINE_URL = "https://projects.knownelement.com"
DISCOURSE_URL = "https://community.turnsys.com"
STATE_DIR = Path("/app/state")
# Identities that are NOT owned by the COO BW account
EXCLUDE = {"cco", "ctpo", "board-secretariat", "director-audit"}
def verify_gitea(token):
req = urllib.request.Request(
f"{GITEA_URL}/api/v1/user",
headers={"Authorization": f"token {token}"},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r).get("login")
def verify_redmine(key):
req = urllib.request.Request(
f"{REDMINE_URL}/users/current.json",
headers={"X-Redmine-API-Key": key},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.load(r).get("user", {}).get("login")
def verify_discourse(key):
req = urllib.request.Request(
f"{DISCOURSE_URL}/notifications.json",
headers={"User-Api-Key": key},
)
with urllib.request.urlopen(req, timeout=15) as r:
r.read()
return "ok"
def _validate_agent(bw, context, agent, results):
"""Validate one agent: fresh login + API credential checks."""
name = agent["name"]
email = agent.get("cloudron_email", f"{name}@turnsys.com")
item = f"{name} Cloudron"
row = {"name": name, "cloudron": False, "totp": False,
"gitea": None, "redmine": None, "discourse": None, "err": ""}
try:
if not bw.item_exists(item):
row["err"] = "no BW item"
results.append(row)
print(f"[{name:20s}] FAIL: no Cloudron item in BW")
return
password = bw.get_item_password(item)
page = context.new_page()
page.goto(f"{CLOUDRON_BASE}/login.html",
wait_until="networkidle", timeout=20000)
page.wait_for_timeout(1500)
page.wait_for_selector("#inputPassword", timeout=15000)
page.click("#inputUsername")
page.keyboard.type(email)
page.click("#inputPassword")
page.keyboard.type(password)
page.locator('[role="button"]:has-text("Log in")').first.click()
page.wait_for_timeout(4000)
totp_el = page.query_selector("#inputTotpToken")
if totp_el and totp_el.is_visible():
row["totp"] = True # 2FA prompt appeared = 2FA is ON
code = bw.get_totp(item)
totp_el.click()
page.keyboard.type(code)
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
authed = ("login" not in page.url.lower()
and "openid" not in page.url.lower()
and "setupaccount" not in page.url.lower())
row["cloudron"] = authed
if not authed:
page.screenshot(path=str(
STATE_DIR / f"validate-fail-{name}-{time.strftime('%H%M%S')}.png"))
row["err"] = f"login stuck at {page.url[:60]}"
page.close()
except Exception as e:
row["err"] = str(e)[:80]
# API credential checks (failures recorded, not fatal)
for sysname, verifier, bwitem in [
("gitea", verify_gitea, f"{name} Gitea"),
("redmine", verify_redmine, f"{name} Redmine"),
("discourse", verify_discourse, f"{name} Discourse"),
]:
try:
if bw.item_exists(bwitem):
row[sysname] = verifier(bw.get_item_password(bwitem))
else:
row[sysname] = "-"
except Exception as e:
row[sysname] = f"ERR {str(e)[:40]}"
results.append(row)
g = row["gitea"] if row["gitea"] is not None else "?"
r = row["redmine"] if row["redmine"] is not None else "?"
d = row["discourse"] if row["discourse"] is not None else "?"
status = "PASS" if row["cloudron"] and row["totp"] else "FAIL"
print(f"[{name:20s}] {status} cloudron={row['cloudron']} "
f"2fa={row['totp']} gitea={g} redmine={r} discourse={d} "
f"{row['err']}")
def main():
bw = BitwardenHelper(
client_id=os.environ["BW_CLIENTID"],
client_secret=os.environ["BW_CLIENTSECRET"],
password=os.environ["BW_PASSWORD"],
server_url=os.environ.get("BW_SERVER", ""),
)
bw.login()
manifest = yaml.safe_load(open("/app/agents.yaml"))
agents = [a for a in manifest["agents"] if a["name"] not in EXCLUDE]
results = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
for agent in agents:
# Fresh context per agent: a shared context carries the previous
# agent's session cookie and login.html never shows the form.
context = browser.new_context(viewport={"width": 1280, "height": 1024})
try:
_validate_agent(bw, context, agent, results)
finally:
context.close()
browser.close()
print("\n=== VALIDATION MATRIX ===")
fails = 0
for row in results:
ok = row["cloudron"] and row["totp"]
if not ok:
fails += 1
print(f" {row['name']:20s} {'OK ' if ok else 'FAIL'} {row['err']}")
sys.exit(1 if fails else 0)
if __name__ == "__main__":
main()