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:
+46
-2
@@ -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)
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -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)")
|
||||||
|
|||||||
Reference in New Issue
Block a user