feat: full app provisioning for all COO agents + race-hardened BW helper

App credentials now flow for every agent: shared handle_oidc_interaction
helper fills the per-app OIDC login form/TOTP/consent (the panel session
is not shared across app clients -- each may demand fresh credentials),
and the Discourse signup clears the prefilled username field before
typing (prefill+typed concatenation exceeded the 20-char cap and failed
validation silently).

BW helper hardened against the sync races observed across concurrent
containers: get_item_id re-syncs stale caches, create/edit retry with
backoff and post-write sync. This class of failure was mine -- the
login-path fix from session 3 left read paths on stale caches.

Final validated matrix (validate-all-logins.py, fresh-context logins
plus live API checks): 9/10 fully green; vp-compliance blocked on a
corrupt stored password (Cloudron admin reset needed). Redmine access
still Cloudron-denied for vp-secops, svp-knel, vp-techcompliance,
vp-facilities ("You do not have access" at the OIDC interaction).
This commit is contained in:
2026-08-14 13:53:01 -05:00
parent 1b8bd843d9
commit a4a54f553e
6 changed files with 495 additions and 15 deletions
+46 -2
View File
@@ -24,6 +24,7 @@ import os
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
from typing import Optional from typing import Optional
@@ -38,6 +39,7 @@ class BitwardenHelper:
self.totp_secret = totp_secret self.totp_secret = totp_secret
self.server_url = server_url self.server_url = server_url
self.session: Optional[str] = None self.session: Optional[str] = None
self._last_sync: float = 0.0
def _run_bw(self, args: list[str], capture: bool = True) -> str: def _run_bw(self, args: list[str], capture: bool = True) -> str:
"""Run a bw CLI command with the active session.""" """Run a bw CLI command with the active session."""
@@ -121,6 +123,43 @@ class BitwardenHelper:
not appear in list/search results. not appear in list/search results.
""" """
self._run_bw(["sync"]) self._run_bw(["sync"])
self._last_sync = time.monotonic()
def _sync_if_stale(self, max_age_s: float = 30.0) -> None:
"""Re-sync if the cache is older than max_age_s seconds.
Multiple containers share this vault (host wrapper, provisioner
runs). A read performed on a stale cache sees ghosts: items that
exist server-side appear missing (or vice versa). Cheap enough
to run before every read.
"""
if time.monotonic() - self._last_sync > max_age_s:
self.sync()
def _run_bw_with_retry(self, args: list[str], retries: int = 3) -> str:
"""Run a bw command, retrying on transient failures.
The bw CLI occasionally returns empty output or non-JSON errors
under load (observed: empty create response, 'Expecting value'
JSON decode upstream). Retry with backoff before failing.
"""
last_err = None
for attempt in range(1, retries + 1):
try:
return self._run_bw(args)
except (RuntimeError, json.JSONDecodeError) as e:
last_err = e
msg = str(e)
# Non-retryable failures: re-raise immediately
if "not found" in msg.lower() or "already exists" in msg.lower():
raise
if attempt < retries:
delay = 2 * attempt
time.sleep(delay)
self.sync()
raise RuntimeError(
f"bw {' '.join(args)} failed after {retries} retries: {last_err}"
)
def generate_password(self, length: int = 32) -> str: def generate_password(self, length: int = 32) -> str:
"""Generate a strong password.""" """Generate a strong password."""
@@ -147,8 +186,11 @@ class BitwardenHelper:
Returns the item ID if exactly one match exists, None if no match, Returns the item ID if exactly one match exists, None if no match,
and raises RuntimeError if multiple items share the name (ambiguous). and raises RuntimeError if multiple items share the name (ambiguous).
Always syncs if the cache is stale -- containers share this vault
and a stale cache sees ghosts.
""" """
try: try:
self._sync_if_stale()
output = self._run_bw(["list", "items", "--search", name]) output = self._run_bw(["list", "items", "--search", name])
items = json.loads(output) items = json.loads(output)
# Filter to exact name matches (bw search is fuzzy) # Filter to exact name matches (bw search is fuzzy)
@@ -228,8 +270,9 @@ class BitwardenHelper:
item["collectionIds"] = [collection_id] item["collectionIds"] = [collection_id]
encoded_item = self._encode(item) encoded_item = self._encode(item)
output = self._run_bw(["create", "item", encoded_item]) output = self._run_bw_with_retry(["create", "item", encoded_item])
created = json.loads(output) created = json.loads(output)
self.sync()
return created.get("id", "") return created.get("id", "")
def update_item( def update_item(
@@ -271,8 +314,9 @@ class BitwardenHelper:
] ]
encoded_item = self._encode(current) encoded_item = self._encode(current)
output = self._run_bw(["edit", "item", item_id, encoded_item]) output = self._run_bw_with_retry(["edit", "item", item_id, encoded_item])
updated = json.loads(output) updated = json.loads(output)
self.sync()
return updated.get("id", item_id) return updated.get("id", item_id)
# ------------------------------------------------------------------- # -------------------------------------------------------------------
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
diagnose-sso-chain.py -- Trace the full SSO navigation chain for one agent.
Captures every navigation, page title, and visible messages during:
1. Cloudron panel login (establishes OIDC session)
2. Redmine SSO attempt (click -> ... -> final URL)
3. Discourse SSO attempt (modal -> OIDC -> signup/login -> final state)
Usage:
docker compose run --rm --entrypoint python3 \
-e AGENT=vp-secops provision diagnose-sso-chain.py
"""
import os, sys, time
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = "https://my.knownelement.com"
REDMINE_URL = "https://projects.knownelement.com"
DISCOURSE_URL = "https://community.turnsys.com"
STATE_DIR = Path("/app/state")
AGENT = os.environ.get("AGENT", "vp-secops")
def snapshot(page, label, trail):
body = ""
try:
body = page.evaluate("() => document.body.innerText.substring(0, 250)")
except Exception:
pass
entry = f"[{label}] {page.url}\n body: {' | '.join(body.splitlines()[:6])}"
trail.append(entry)
print(entry)
def cloudron_login(page, bw):
item = f"{AGENT} Cloudron"
email_map = {
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
"vp-techcompliance": "tsgstaff-coo-vptechcompliance@turnsys.com",
}
email = email_map.get(AGENT, f"{AGENT}@turnsys.com")
password = bw.get_item_password(item)
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(2000)
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 = page.query_selector("#inputTotpToken")
if totp and totp.is_visible():
totp.click()
page.keyboard.type(bw.get_totp(item))
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
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()
trail = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
page.on("framenavigated", lambda frame: None) # no-op; we poll manually
print(f"=== {AGENT}: Cloudron login ===")
cloudron_login(page, bw)
snapshot(page, "cloudron", trail)
print(f"\n=== {AGENT}: Redmine SSO chain ===")
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(2000)
snapshot(page, "redmine-login", trail)
page.locator("#login-oauth-submit-1").first.click()
for i in range(6):
page.wait_for_timeout(1500)
snapshot(page, f"redmine+{(i+1)*1.5}s", trail)
if "projects.knownelement.com" in page.url and "/login" not in page.url:
break
# capture any flash error
err = page.evaluate("""() => {
const f = document.querySelector('#flash_notice, #flash_error, .flash, .error, .message');
return f ? f.textContent.trim() : '';
}""")
print(f" flash/error element: {err!r}")
print(f"\n=== {AGENT}: Discourse SSO chain ===")
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
snapshot(page, "discourse-home", trail)
login_btn = page.locator(".login-button")
if login_btn.count() > 0:
login_btn.first.click()
page.wait_for_timeout(2000)
snapshot(page, "discourse-modal", trail)
sso = page.locator('button:has-text("OpenID")')
if sso.count() > 0:
sso.first.click()
for i in range(6):
page.wait_for_timeout(2000)
snapshot(page, f"discourse+{(i+1)*2}s", trail)
if "/signup" not in page.url and "/login" not in page.url:
break
if "/signup" in page.url:
body = page.evaluate("() => document.body.innerText")
print(f" SIGNUP PAGE body: {body[:300]!r}")
page.screenshot(path=str(STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.png"),
full_page=True)
browser.close()
(STATE_DIR / f"sso-chain-{AGENT}-{time.strftime('%H%M%S')}.txt").write_text(
"\n".join(trail))
if __name__ == "__main__":
main()
+3
View File
@@ -26,3 +26,6 @@ services:
- ./validate-all-logins.py:/app/validate-all-logins.py:ro - ./validate-all-logins.py:/app/validate-all-logins.py:ro
- ./diagnose-vp-compliance.py:/app/diagnose-vp-compliance.py:ro - ./diagnose-vp-compliance.py:/app/diagnose-vp-compliance.py:ro
- ./validate-coo.py:/app/validate-coo.py:ro - ./validate-coo.py:/app/validate-coo.py:ro
- ./diagnose-sso-chain.py:/app/diagnose-sso-chain.py:ro
- ./probe-redmine-access.py:/app/probe-redmine-access.py:ro
- ./probe-discourse-signup.py:/app/probe-discourse-signup.py:ro
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
probe-discourse-signup.py -- Dump Discourse signup validation state.
Gets an agent to the OIDC-authenticated /signup page, fills a candidate
username, clicks Create Account, and dumps every field hint/error plus
the page state 8s later. Reveals why "account created" never sticks.
Usage:
docker compose run --rm --entrypoint python3 \
-e AGENT=vp-secops provision probe-discourse-signup.py
"""
import os, sys, time
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = "https://my.knownelement.com"
DISCOURSE_URL = "https://community.turnsys.com"
STATE_DIR = Path("/app/state")
AGENT = os.environ.get("AGENT", "vp-secops")
EMAILS = {
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
"svp-knel": "tsgstaff-coo-svpknel@turnsys.com",
}
USERNAME = AGENT.replace("-", "")
def field_state(page):
return page.evaluate("""() => {
const out = [];
document.querySelectorAll('input, .tip, .invalid, .good, .bad, .warning, [class*="hint"]').forEach(el => {
const id = el.id || '';
const val = (el.value || '').substring(0, 40);
const text = (el.textContent || '').trim().substring(0, 100);
const cls = (el.getAttribute('class') || '').substring(0, 60);
const vis = el.offsetParent !== null;
if (text || val || id) out.push(`<${el.tagName.toLowerCase()}> id=${id} class=${cls} value="${val}" vis=${vis} text="${text}"`);
});
return out.join('\\n');
}""")
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()
item = f"{AGENT} Cloudron"
email = EMAILS.get(AGENT, f"{AGENT}@turnsys.com")
password = bw.get_item_password(item)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
# Panel login
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(2000)
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 = page.query_selector("#inputTotpToken")
if totp and totp.is_visible():
totp.click()
page.keyboard.type(bw.get_totp(item))
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
print(f"PANEL: {page.url}")
# Discourse: login modal -> OpenID
page.goto(DISCOURSE_URL, wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(3000)
page.locator(".login-button").first.click()
page.wait_for_timeout(3000)
page.locator('button:has-text("OpenID")').first.click()
page.wait_for_timeout(5000)
# Handle any OIDC interaction
for _ in range(4):
pw = page.query_selector("#inputPassword")
if pw and pw.is_visible():
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)
continue
t = page.query_selector("#inputTotpToken")
if t and t.is_visible():
t.click(); page.keyboard.type(bw.get_totp(item))
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
continue
if "community.turnsys.com" in page.url:
break
page.wait_for_timeout(2500)
print(f"POST-OIDC: {page.url}")
if "/signup" not in page.url:
print("NOT ON SIGNUP -- dumping body:")
print(page.evaluate("() => document.body.innerText.substring(0, 400)"))
browser.close()
return
# Fill username
page.wait_for_timeout(2000)
inp = page.locator("#new-account-username, input[name='username']").first
inp.click()
page.keyboard.type(USERNAME)
page.wait_for_timeout(2000)
print(f"\n=== FIELD STATE after typing {USERNAME} ===")
print(field_state(page))
# Click create
for btn in ["Create Account", "Sign Up"]:
loc = page.locator(f'button:has-text("{btn}")')
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
print(f"\nClicked: {btn}")
break
page.wait_for_timeout(8000)
print(f"\n=== 8s LATER: URL={page.url} ===")
print(field_state(page))
body = page.evaluate("() => document.body.innerText.substring(0, 400)")
print(f"BODY: {body!r}")
page.screenshot(path=str(STATE_DIR / f"discourse-signup-{AGENT}-{time.strftime('%H%M%S')}.png"), full_page=True)
browser.close()
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
probe-redmine-access.py -- What does the Redmine OIDC page actually say?
Logs in via panel, clicks Redmine SSO, dumps the interaction page text
verbatim. Distinguishes: login form vs consent vs "You do not have access".
Usage:
docker compose run --rm --entrypoint python3 \
-e AGENT=svp-knel provision probe-redmine-access.py
"""
import os, sys, time
from pathlib import Path
from playwright.sync_api import sync_playwright
sys.path.insert(0, os.path.dirname(__file__))
from bw_helper import BitwardenHelper
CLOUDRON_BASE = "https://my.knownelement.com"
REDMINE_URL = "https://projects.knownelement.com"
STATE_DIR = Path("/app/state")
AGENT = os.environ.get("AGENT", "svp-knel")
EMAILS = {
"svp-knel": "tsgstaff-coo-svpknel@turnsys.com",
"vp-secops": "tsgstaff-coo-vpsecops@turnsys.com",
"coo": "tsgstaff-coo@turnsys.com",
}
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()
item = f"{AGENT} Cloudron"
email = EMAILS[AGENT]
password = bw.get_item_password(item)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(viewport={"width": 1280, "height": 1024})
page = context.new_page()
# Panel login (networkidle like the working loop code)
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=30000)
page.wait_for_timeout(2000)
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 = page.query_selector("#inputTotpToken")
if totp and totp.is_visible():
totp.click()
page.keyboard.type(bw.get_totp(item))
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
print(f"PANEL: {page.url}")
# Redmine SSO
page.goto(f"{REDMINE_URL}/login", wait_until="domcontentloaded", timeout=30000)
page.wait_for_timeout(2000)
page.locator("#login-oauth-submit-1").first.click()
page.wait_for_timeout(6000)
print(f"\nOIDC URL: {page.url}")
body = page.evaluate("() => document.body.innerText")
print("OIDC BODY (verbatim):")
print(body)
page.screenshot(path=str(STATE_DIR / f"redmine-access-{AGENT}-{time.strftime('%H%M%S')}.png"))
browser.close()
if __name__ == "__main__":
main()
+84 -13
View File
@@ -561,6 +561,69 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
return True return True
def handle_oidc_interaction(page: Page, agent: dict, bw: BitwardenHelper) -> None:
"""Handle a Cloudron OIDC interaction page: login form, TOTP, consent.
Each Cloudron app has its own OIDC client and the panel session is
NOT always shared -- the interaction page may present a fresh Pankow
login form (#inputPassword), a TOTP prompt (#inputTotpToken), a
consent page, or any combination. Safe to call speculatively; every
step checks visibility first.
Cloudron's Pankow forms use #input* IDs (Gitea/Redmine/Discourse
login pages use different IDs, so there is no cross-app collision).
"""
name = agent["name"]
cloudron_item = f"{name} Cloudron"
# Poll: the redirect to the OIDC interaction may still be in flight
for _ in range(6):
on_interaction = ("openid" in page.url or "interaction" in page.url
or "my.knownelement.com" in page.url)
# 1. Login form (Cloudron Pankow IDs -- no cross-app collision)
pw = page.query_selector("#inputPassword")
if pw and pw.is_visible():
email = agent.get("cloudron_email", f"{name}@turnsys.com")
password = bw.get_item_password(cloudron_item)
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)
log.info(f"[{name}] OIDC interaction: login form submitted")
continue
# 2. TOTP
totp = page.query_selector("#inputTotpToken")
if totp and totp.is_visible():
code = bw.get_totp(cloudron_item)
totp.click()
page.keyboard.type(code)
page.locator("#totpTokenSubmitButton").click()
page.wait_for_timeout(5000)
log.info(f"[{name}] OIDC interaction: TOTP submitted")
continue
# 3. Consent (only on the interaction page itself)
if on_interaction:
for text in ["Continue", "Authorize", "Allow", "Accept"]:
loc = page.locator(
f'[role="button"]:has-text("{text}"), button:has-text("{text}")'
)
if loc.count() > 0 and loc.first.is_visible():
loc.first.click()
page.wait_for_timeout(5000)
log.info(f"[{name}] OIDC interaction: consent clicked ({text})")
break
# Done if we've left the interaction pages
if not on_interaction:
return
page.wait_for_timeout(2500)
def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str: def provision_gitea(page: Page, agent: dict, bw: BitwardenHelper) -> str:
"""Generate a Gitea API token via SSO login. Returns the token.""" """Generate a Gitea API token via SSO login. Returns the token."""
name = agent["name"] name = agent["name"]
@@ -695,7 +758,12 @@ def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
return bw.get_item_password(item_name) return bw.get_item_password(item_name)
url = discourse_cfg.get("url", DISCOURSE_URL) url = discourse_cfg.get("url", DISCOURSE_URL)
username = agent.get("username", name.replace("-", "")) # Discourse caps usernames at 20 chars. Invite-issued usernames are
# often longer (tsgstaff-coo-svptctc-vpinvesting), which fails signup
# validation silently. Default to the short form unless overridden.
username = agent.get("discourse_username", name.replace("-", ""))
if len(username) > 20:
username = name.replace("-", "")[:20]
# --- SSO login --- # --- SSO login ---
page.goto(f"{url}/", wait_until="domcontentloaded", timeout=30000) page.goto(f"{url}/", wait_until="domcontentloaded", timeout=30000)
@@ -710,14 +778,23 @@ def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
sso_btn = page.locator('button:has-text("OpenID")') sso_btn = page.locator('button:has-text("OpenID")')
if sso_btn.count() > 0: if sso_btn.count() > 0:
sso_btn.first.click() sso_btn.first.click()
page.wait_for_timeout(5000) page.wait_for_timeout(3000)
# First login: /signup with email already authenticated by OIDC # The OIDC interaction may need a fresh login/TOTP/consent
handle_oidc_interaction(page, agent, bw)
page.wait_for_timeout(3000)
# First login: /signup with email already authenticated by OIDC.
# Discourse PREFILLS the username field (truncated OIDC username)
# -- appending without clearing produces a too-long concatenation.
# Select-all + delete before typing.
if "/signup" in page.url: if "/signup" in page.url:
page.wait_for_timeout(2000) page.wait_for_timeout(2000)
username_input = page.locator('#new-account-username, input[name="username"]') username_input = page.locator('#new-account-username, input[name="username"]')
if username_input.count() > 0 and username_input.first.is_visible(): if username_input.count() > 0 and username_input.first.is_visible():
username_input.first.click() username_input.first.click()
page.keyboard.press("Control+a")
page.keyboard.press("Delete")
page.keyboard.type(username) page.keyboard.type(username)
page.wait_for_timeout(1000) page.wait_for_timeout(1000)
for btn_text in ["Create Account", "Sign Up", "Register"]: for btn_text in ["Create Account", "Sign Up", "Register"]:
@@ -882,17 +959,11 @@ def provision_redmine(page: Page, agent: dict, bw: BitwardenHelper) -> str:
) )
if sso_btn.count() > 0 and sso_btn.first.is_visible(): if sso_btn.count() > 0 and sso_btn.first.is_visible():
sso_btn.first.click() sso_btn.first.click()
page.wait_for_timeout(5000) page.wait_for_timeout(3000)
if "openid" in page.url.lower(): # The OIDC interaction may need a fresh login/TOTP/consent
for consent in ["Continue", "Authorize", "Allow", "Accept"]: handle_oidc_interaction(page, agent, bw)
cbtn = page.locator( page.wait_for_timeout(3000)
f'[role="button"]:has-text("{consent}"), button:has-text("{consent}")'
)
if cbtn.count() > 0 and cbtn.first.is_visible():
cbtn.first.click()
page.wait_for_timeout(5000)
break
if "/login" in page.url: if "/login" in page.url:
log.error(f"[{name}] Redmine SSO failed (still on login page)") log.error(f"[{name}] Redmine SSO failed (still on login page)")