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:
+84
-13
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user