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
+84 -13
View File
@@ -561,6 +561,69 @@ def sso_login(page: Page, system_url: str, agent: dict, bw: BitwardenHelper,
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:
"""Generate a Gitea API token via SSO login. Returns the token."""
name = agent["name"]
@@ -695,7 +758,12 @@ def provision_discourse(page: Page, agent: dict, bw: BitwardenHelper) -> str:
return bw.get_item_password(item_name)
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 ---
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")')
if sso_btn.count() > 0:
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:
page.wait_for_timeout(2000)
username_input = page.locator('#new-account-username, input[name="username"]')
if username_input.count() > 0 and username_input.first.is_visible():
username_input.first.click()
page.keyboard.press("Control+a")
page.keyboard.press("Delete")
page.keyboard.type(username)
page.wait_for_timeout(1000)
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():
sso_btn.first.click()
page.wait_for_timeout(5000)
page.wait_for_timeout(3000)
if "openid" in page.url.lower():
for consent in ["Continue", "Authorize", "Allow", "Accept"]:
cbtn = page.locator(
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
# The OIDC interaction may need a fresh login/TOTP/consent
handle_oidc_interaction(page, agent, bw)
page.wait_for_timeout(3000)
if "/login" in page.url:
log.error(f"[{name}] Redmine SSO failed (still on login page)")