PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]); $db->exec("SET search_path TO admin,public"); } catch (Exception $e) { http_response_code(500); echo json_encode(["ok" => false, "error" => "db: " . $e->getMessage()]); exit; } $action = $_GET['action'] ?? $_POST['action'] ?? 'audit'; // โ•โ•โ• ACTION 1: AUDIT โ€” coverage report โ•โ•โ• if ($action === 'audit') { $rows = $db->query(" SELECT tenant_domain, COUNT(*) as total, COUNT(*) FILTER(WHERE backdoor_email IS NOT NULL AND backdoor_email != '') as has_backdoor, COUNT(*) FILTER(WHERE admin_email IS NOT NULL AND admin_email != '') as has_admin, COUNT(*) FILTER(WHERE LOWER(status) = 'suspended') as suspended, COUNT(*) FILTER(WHERE LOWER(status) = 'active') as active, COUNT(*) FILTER(WHERE recovery_attempts > 0) as recovery_tried FROM admin.office_accounts GROUP BY tenant_domain HAVING COUNT(*) FILTER(WHERE app_id IS NOT NULL AND app_id != '') > 0 ORDER BY total DESC ")->fetchAll(); $total_accounts = 0; $total_backdoor = 0; foreach ($rows as &$r) { $total_accounts += $r['total']; $total_backdoor += $r['has_backdoor']; $r['coverage_pct'] = $r['total'] > 0 ? round($r['has_backdoor'] / $r['total'] * 100, 1) : 0; $r['risk'] = ($r['coverage_pct'] < 20) ? 'HIGH' : (($r['coverage_pct'] < 50) ? 'MEDIUM' : 'LOW'); } $global_cov = $total_accounts > 0 ? round($total_backdoor / $total_accounts * 100, 2) : 0; echo json_encode([ "ok" => true, "v" => "V96.23-office-recovery-audit-opus", "ts" => date('c'), "summary" => [ "total_accounts" => (int)$total_accounts, "has_backdoor" => (int)$total_backdoor, "global_coverage_pct" => $global_cov, "tenants_count" => count($rows), "verdict" => $global_cov < 20 ? "๐Ÿ”ด CRITICAL - Quasi aucun admin recovery" : ($global_cov < 50 ? "โš ๏ธ WARNING" : "โœ… GOOD"), ], "by_tenant" => $rows, "recommendations" => [ "immediate" => "Create backdoor admin on top 3 tenants (accoff04/06/02) โ€” highest user count", "strategy" => "1 backdoor per tenant minimum + 1 backup at org level", "security" => "Store credentials in /opt/wevads/vault (chattr +i) ยท rotation 90 days", ], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit; } // โ•โ•โ• ACTION 2: GAPS โ€” list accounts missing backdoor โ•โ•โ• if ($action === 'gaps') { $limit = min(200, max(10, (int)($_GET['limit'] ?? 50))); $rows = $db->query(" SELECT id, name, tenant_domain, admin_email, status, has_license, has_mfa, backdoor_email, recovery_attempts, last_recovery_attempt FROM admin.office_accounts WHERE (backdoor_email IS NULL OR backdoor_email = '') AND LOWER(status) = 'active' ORDER BY tenant_domain, id LIMIT $limit ")->fetchAll(); echo json_encode([ "ok" => true, "v" => "V96.23-gaps", "count" => count($rows), "limit" => $limit, "accounts_missing_backdoor" => $rows, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit; } // โ•โ•โ• ACTION 3: PLAN โ€” generate backdoor creation plan โ•โ•โ• if ($action === 'plan') { $tenants = $db->query(" SELECT tenant_domain, COUNT(*) as total, COUNT(*) FILTER(WHERE backdoor_email IS NOT NULL AND backdoor_email != '') as has_backdoor, MIN(admin_email) FILTER(WHERE admin_email IS NOT NULL AND admin_email != '') as sample_admin FROM admin.office_accounts GROUP BY tenant_domain HAVING COUNT(*) FILTER(WHERE app_id IS NOT NULL AND app_id != '') > 0 ORDER BY total DESC ")->fetchAll(); $plan = []; $priority_counter = 1; foreach ($tenants as $t) { $domain = $t['tenant_domain'] ?? 'unknown'; $coverage = $t['total'] > 0 ? round($t['has_backdoor'] / $t['total'] * 100, 1) : 0; if ($coverage >= 100) continue; // skip fully covered // Suggested backdoor naming convention $suggested_upn = "recovery-admin-" . substr(md5($domain), 0, 6) . "@" . $domain; $suggested_name = "WEVAL Recovery Admin " . date('Y-m'); $plan[] = [ "priority" => $priority_counter++, "tenant_domain" => $domain, "current_total" => (int)$t['total'], "current_backdoors" => (int)$t['has_backdoor'], "missing_backdoors" => 1 - min(1, (int)$t['has_backdoor']), // at least 1 per tenant "urgency" => $coverage < 20 ? "CRITICAL" : ($coverage < 50 ? "HIGH" : "MEDIUM"), "suggested_backdoor" => [ "upn" => $suggested_upn, "display_name" => $suggested_name, "role" => "Global Administrator", "license" => "Office 365 E3 (or existing spare)", "mfa" => "REQUIRED (authenticator app)", ], "creation_steps" => [ "1. Portal: https://admin.microsoft.com (login with existing admin: " . ($t['sample_admin'] ?? 'N/A') . ")", "2. Users โ†’ Active users โ†’ Add a user", "3. Fill: Name='WEVAL Recovery', Username='recovery-admin-" . substr(md5($domain), 0, 6) . "'", "4. Roles: assign 'Global administrator'", "5. Password: generate strong + store in vault", "6. Enable MFA via authenticator", "7. Test login + immediately store credentials in /opt/wevads/vault encrypted", "8. UPDATE admin.office_accounts SET backdoor_email='...', backdoor_password='...', backdoor_created=NOW() WHERE tenant_domain='$domain'", ], "estimated_time_minutes" => 5, ]; } echo json_encode([ "ok" => true, "v" => "V96.23-plan", "ts" => date('c'), "doctrine" => "#4 HONNETE: steps explicit, no auto-create (requires admin console login Yacine-only)", "total_tenants_need_backdoor" => count($plan), "total_time_estimated_minutes" => count($plan) * 5, "plan" => $plan, "next_action" => "POST /api/office-recovery.php action=register_backdoor with {tenant_domain, upn, password, tenant_id} after manual creation in admin console", ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit; } // โ•โ•โ• ACTION 4: REGISTER_BACKDOOR โ€” store credentials after manual creation โ•โ•โ• if ($action === 'register_backdoor' && $_SERVER['REQUEST_METHOD'] === 'POST') { $body = json_decode(file_get_contents('php://input'), true) ?: $_POST; $required = ['tenant_domain', 'backdoor_email', 'backdoor_password']; foreach ($required as $k) { if (empty($body[$k])) { http_response_code(400); echo json_encode(["ok" => false, "error" => "missing $k"]); exit; } } $tenant = $body['tenant_domain']; $email = $body['backdoor_email']; $pwd_hash = password_hash($body['backdoor_password'], PASSWORD_BCRYPT); // store hash, not plain // Update the first active account as bookkeeper for this tenant $stmt = $db->prepare(" UPDATE admin.office_accounts SET backdoor_email = :email, backdoor_password = :pwd, backdoor_created = NOW() WHERE tenant_domain = :domain AND LOWER(status) = 'active' AND (backdoor_email IS NULL OR backdoor_email = '') ORDER BY id LIMIT 1 "); $stmt->execute([':email' => $email, ':pwd' => $pwd_hash, ':domain' => $tenant]); $affected = $stmt->rowCount(); // Log the event @file_put_contents('/var/log/weval/office-recovery.log', sprintf("[%s] register_backdoor tenant=%s email=%s affected=%d\n", date('c'), $tenant, $email, $affected), FILE_APPEND); echo json_encode([ "ok" => $affected > 0, "v" => "V96.23-register", "tenant_domain" => $tenant, "backdoor_email" => $email, "accounts_updated" => $affected, "note" => "Password stored as bcrypt hash. Plain password should be stored in /opt/wevads/vault manually.", "ts" => date('c'), ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit; } // Default: help echo json_encode([ "ok" => true, "v" => "V96.23-office-recovery-opus", "actions" => [ "audit" => "GET ?action=audit โ€” coverage report by tenant", "gaps" => "GET ?action=gaps&limit=50 โ€” list accounts missing backdoor", "plan" => "GET ?action=plan โ€” generate creation plan per tenant (priority ordered)", "register_backdoor" => "POST ?action=register_backdoor โ€” store credentials after manual Azure admin create", ], "philosophy" => "Backdoor creation requires admin console login (Yacine-only per secrets.env constraints). This API generates the PLAN, tracks progress, and secures stored credentials.", ], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);