fix: credential lifecycle safety -- update_item, duplicate prevention, tests

CRITICAL FIX: The --enable-2fa flow was creating duplicate BW items
instead of updating in place, which led to ambiguous item resolution
and data integrity issues. This was a severe failure in core
credential lifecycle operations.

Changes:
- bw_helper.py: Complete rewrite with safety guarantees
  - update_item(): modifies existing item in place by ID, preserves
    all fields not being updated
  - create_item(): refuses to create duplicates (raises if item exists)
  - get_item_id(): resolves name to ID, raises on ambiguous matches
  - get_item(): returns full item JSON
  - NO delete_item method exists by design -- credential deletion
    is a manual operation only
- provision-agent.py: --enable-2fa now uses update_item() instead
  of create_item() to add TOTP to existing credentials
- Dockerfile: non-root user with correct BW state directory ownership
- docker-compose.yml: bind mount for BW state (proper permissions)
- test_bw_helper.py: 10 tests covering full lifecycle
  (create, read, duplicate rejection, update password, update TOTP,
  field preservation, no-delete verification)
  Tests 1-4 verified passing against live Vaultwarden instance.
- requirements.txt: added pytest

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
TSYS Group COO
2026-08-13 14:47:57 -05:00
parent 04ece5234a
commit f638105614
8 changed files with 442 additions and 118 deletions
+120 -42
View File
@@ -162,57 +162,110 @@ def enroll_cloudron(
def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
"""
Navigate to Cloudron 2FA settings and enable TOTP.
Navigate to Cloudron security settings and enable TOTP 2FA.
Returns the TOTP secret.
Cloudron's security page uses Angular with #inputTotpToggle and
#inputTotpSecret/#inputTotpToken selectors.
Returns the TOTP secret (empty string if already enabled or failed).
"""
name = agent["name"]
log.info(f"[{name}] Enabling 2FA on Cloudron account")
# Navigate to account settings
page.goto(f"{CLOUDRON_BASE}/settings.html#account", wait_until="networkidle")
page.wait_for_timeout(2000)
# Navigate to security/2FA settings. Cloudron users access their own
# settings at /#/profile (not /#/security which is admin-only).
page.evaluate('() => window.location.hash = "#/profile"')
page.wait_for_timeout(3000)
# Click "Enable 2FA" button
enable_btn = page.query_selector('button:has-text("Enable"), button:has-text("2FA"), a:has-text("Enable")')
if not enable_btn:
log.warning(f"[{name}] Could not find 2FA enable button — may already be enabled")
# If that didn't work, try clicking the profile/settings nav link
if "#/profile" not in page.url:
for nav_text in ["Profile", "Settings", "Account", "Security"]:
sec_link = page.locator(f'a[href*="#/profile"], a[href*="#/security"], a:has-text("{nav_text}")')
if sec_link.count() > 0:
sec_link.first.click()
page.wait_for_timeout(3000)
break
_debug_dump(page, f"cloudron-security-{name}")
# Check if 2FA is already enabled by looking at page text
page_text = page.evaluate("() => document.body.innerText")
if "totp" in page_text.lower() and "enabled" in page_text.lower():
log.info(f"[{name}] 2FA already enabled on Cloudron")
return ""
enable_btn.click()
page.wait_for_timeout(2000)
# Click the TOTP enable/setup button on the profile page
# Cloudron profile has a TOTP section with an Enable button
totp_clicked = False
for selector in [
'button:has-text("Enable")',
'button:has-text("Setup")',
'a:has-text("Enable")',
'input[value="Enable"]',
'button:has-text("TOTP")',
]:
btn = page.locator(selector)
if btn.count() > 0:
btn.first.click()
page.wait_for_timeout(3000)
totp_clicked = True
break
# Extract TOTP secret from the QR code or the manual entry text
# Cloudron shows a QR code and a text secret
secret_text = page.query_selector('.modal-body code, .two-factor-secret, input[readonly]')
if secret_text:
totp_secret = secret_text.text_content().strip().replace(" ", "")
else:
# Try to extract from QR image source (base64)
if not totp_clicked:
log.warning(f"[{name}] Could not find TOTP enable button on profile page")
_debug_dump(page, f"cloudron-2fa-no-btn-{name}")
return ""
_debug_dump(page, f"cloudron-2fa-modal-{name}")
# Extract TOTP secret — Cloudron shows it in a modal after clicking Enable
secret_el = page.query_selector('input[readonly], code, .totp-secret, #inputTotpSecret')
totp_secret = ""
if secret_el:
totp_secret = secret_el.get_attribute("value") or secret_el.text_content()
totp_secret = totp_secret.strip().replace(" ", "")
# Try QR code if text secret not found
if not totp_secret:
qr_img = page.query_selector('img[src*="data:image"]')
if qr_img:
qr_src = qr_img.get_attribute("src")
totp_secret = decode_qr_from_base64(qr_src)
else:
log.error(f"[{name}] Could not extract TOTP secret from 2FA page")
return ""
if not totp_secret:
log.error(f"[{name}] Could not extract TOTP secret from Cloudron 2FA modal")
return ""
log.info(f"[{name}] Extracted TOTP secret: {totp_secret[:4]}...")
# Generate current TOTP code and confirm
# Generate current TOTP code and enter it to confirm
import pyotp
totp_code = pyotp.TOTP(totp_secret).now()
code_input = page.query_selector('input[name="totpToken"], input[name="token"], input[placeholder*="code"]')
# Cloudron TOTP confirmation input
code_input = page.query_selector('#totpTokenInput, #inputTotpToken, input[name="totpToken"]')
if not code_input:
inputs = page.query_selector_all('input[type="text"]')
for inp in inputs:
if inp.is_visible():
code_input = inp
break
if code_input:
code_input.fill(totp_code)
confirm_btn = page.query_selector('button:has-text("Confirm"), button:has-text("Enable"), button[type="submit"]')
if confirm_btn:
confirm_btn.click()
page.wait_for_timeout(2000)
log.info(f"[{name}] 2FA confirmed")
page.wait_for_timeout(500)
# Click confirm button using Playwright locator
for btn_text in ["Confirm", "Enable", "Verify", "OK"]:
btn = page.locator(f'button:has-text("{btn_text}")')
if btn.count() > 0:
btn.first.click()
break
page.wait_for_timeout(3000)
log.info(f"[{name}] 2FA confirmation submitted")
else:
log.warning(f"[{name}] Could not find TOTP confirmation input")
_debug_dump(page, f"cloudron-2fa-no-input-{name}")
return totp_secret
@@ -262,24 +315,35 @@ def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool:
log.info(f"[{name}] Establishing Cloudron panel session")
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle")
page.goto(f"{CLOUDRON_BASE}/login.html", wait_until="networkidle", timeout=15000)
page.wait_for_timeout(2000)
# Already logged in?
if "login" not in page.url.lower():
# Already logged in? (must be on the panel, not redirected to OIDC)
if "login" not in page.url.lower() and "openid" not in page.url.lower():
log.info(f"[{name}] Cloudron panel session already active")
return True
# If redirected to OIDC login page, fill that too
# (Cloudron login.html and OIDC login look similar, both use #inputUsername/#inputPassword)
# Fill Cloudron login form (same selectors as invite page)
page.wait_for_selector("#inputPassword", timeout=15000)
page.fill("#inputUsername", cloudron_email)
page.fill("#inputPassword", password)
# Submit via the btn-primary button (Cloudron uses Angular)
page.evaluate('''() => {
const btn = document.querySelector("button.btn-primary");
if (btn) btn.click();
}''')
# Submit: Cloudron uses different button types on panel vs OIDC pages
page.evaluate("""() => {
const selectors = [
"button.btn-primary",
'button[type="submit"]',
'input[type="submit"]',
"#loginSubmitButton",
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
}
}""")
page.wait_for_timeout(5000)
# Handle TOTP if prompted
@@ -288,16 +352,18 @@ def cloudron_panel_login(page: Page, agent: dict, bw: BitwardenHelper) -> bool:
totp_code = bw.get_totp(cloudron_item)
totp_input.fill(totp_code)
page.evaluate('''() => {
const btn = document.querySelector("button.btn-primary");
if (btn) btn.click();
for (const sel of ["button.btn-primary", 'button[type="submit"]', 'input[type="submit"]']) {
const el = document.querySelector(sel);
if (el) { el.click(); return; }
}
}''')
page.wait_for_timeout(5000)
if "login" not in page.url.lower():
if "login" not in page.url.lower() and "openid" not in page.url.lower():
log.info(f"[{name}] Cloudron panel login successful")
return True
log.error(f"[{name}] Cloudron panel login failed (still on login page)")
log.error(f"[{name}] Cloudron panel login failed (still on login page: {page.url})")
return False
@@ -744,6 +810,7 @@ def main():
parser.add_argument("--manifest", default="agents.yaml", help="Path to manifest file")
parser.add_argument("--agent", help="Provision only this agent")
parser.add_argument("--phase1-only", action="store_true", help="Cloudron enrollment only")
parser.add_argument("--enable-2fa", action="store_true", help="Enable 2FA on existing Cloudron accounts")
parser.add_argument("--dry-run", action="store_true", help="Validate manifest without browser")
parser.add_argument("--headed", action="store_true", help="Show browser (debugging)")
args = parser.parse_args()
@@ -797,8 +864,19 @@ def main():
)
log.info(f"=== Provisioning: {agent['name']} ===")
try:
result = provision_agent(context, agent, bw, args.phase1_only)
all_results.append(result)
if args.enable_2fa:
page = context.new_page()
cloudron_panel_login(page, agent, bw)
totp_secret = enable_cloudron_2fa(page, agent, bw)
if totp_secret:
item_name = f"{agent['name']} Cloudron"
bw.update_item(item_name, totp_secret=totp_secret)
log.info(f"[{agent['name']}] Updated BW item with TOTP secret (in place)")
page.close()
all_results.append({"name": agent["name"], "2fa": bool(totp_secret)})
else:
result = provision_agent(context, agent, bw, args.phase1_only)
all_results.append(result)
except Exception as e:
log.error(f"FAILED: {agent['name']}: {e}")
all_results.append({"name": agent["name"], "error": str(e)})