From b8f89cda8dfa1d1fc4e0f4c65c787b29a1465337 Mon Sep 17 00:00:00 2001 From: Gemini Date: Thu, 2 Jul 2026 12:56:49 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20resolu=C3=A7=C3=A3o=20de=20usu=C3=A1rio?= =?UTF-8?q?s=20self-healing=20+=20syncUsers=20upsert=20+=20fallback=20loga?= =?UTF-8?q?do=20(1.6.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Causa raiz do incidente "autor = admin": cache de usuários vazio no prod. - cache miss -> lookup ao vivo por e-mail no Redmine + upsert no cache - syncUsers nunca mais esvazia a tabela (upsert por redmine_id) - fallback para admin deixa rastro no log - config exibe saúde do cache (contagem + alerta se vazio) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 ++++++++++ hook.php | 3 ++ inc/config.class.php | 19 ++++++++++- inc/redmineapi.class.php | 56 +++++++++++++++++++++++++++------ setup.php | 2 +- templates/config_form.html.twig | 7 +++++ 6 files changed, 92 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62fd7cc..9379f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ Todas as mudanças relevantes deste plugin são documentadas aqui. O formato segue [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/) e o versionamento segue [SemVer](https://semver.org/lang/pt-BR/). +## [1.6.0] + +### Adicionado +- **Resolução de usuários self-healing**: se o e-mail não estiver no cache local, + o plugin busca o usuário **ao vivo** no Redmine e atualiza o cache — cache vazio + ou desatualizado não gera mais lançamentos como admin. +- A seção "Mapeamento de usuários" da config mostra a **quantidade de usuários em + cache**, com alerta destacado quando o cache está vazio. + +### Corrigido +- `syncUsers` fazia delete-all antes de inserir: uma falha no meio deixava o cache + **vazio** (causa raiz de lançamentos saindo como admin). Agora é **upsert** por + usuário + remoção só dos ausentes — o cache nunca fica vazio por falha. +- Fallback para admin agora é **logado** ("Sem par Redmine para o usuário GLPI #N"), + em vez de silencioso. + ## [1.5.7] ### Corrigido diff --git a/hook.php b/hook.php index 0c5c26e..9a8671c 100644 --- a/hook.php +++ b/hook.php @@ -251,6 +251,9 @@ function _plugin_redmine_impersonation_login($glpi_users_id, $redmineProjectId) } $ru = PluginRedmineConfig::resolveRedmineUser($glpi_users_id); if (!$ru || empty($ru['login'])) { + // Sem par no Redmine: o lançamento sairá como admin. Loga para não ser silencioso. + Toolbox::logInFile('redmine', "Sem par Redmine para o usuário GLPI #{$glpi_users_id} (" + . getUserName($glpi_users_id) . ") — lançando como admin. Verifique o e-mail do usuário e o cache (Sincronizar Metadados).\n"); return null; } $roleId = PluginRedmineConfig::getDefaultRoleId(); diff --git a/inc/config.class.php b/inc/config.class.php index 05c4e72..2f4f28b 100644 --- a/inc/config.class.php +++ b/inc/config.class.php @@ -77,13 +77,29 @@ class PluginRedmineConfig extends CommonDBTM { } } - // 2) E-mail auto-match + // 2) E-mail auto-match (cache local) $email = \UserEmail::getDefaultForUser($glpi_users_id); if (!empty($email)) { $ru = $DB->request(['FROM' => 'glpi_plugin_redmine_users', 'WHERE' => ['mail' => $email]]); if (count($ru) > 0) { return $ru->current(); } + + // 3) Self-healing: cache pode estar vazio/desatualizado — busca ao + // vivo no Redmine e grava no cache para as próximas resoluções. + $live = PluginRedmineRedmineapi::findUserByEmail($email); + if ($live && !empty($live['login'])) { + $row = [ + 'redmine_id' => (int) $live['id'], + 'login' => $live['login'], + 'mail' => $live['mail'] ?? $email, + 'name' => trim(($live['firstname'] ?? '') . ' ' . ($live['lastname'] ?? '')), + ]; + $DB->delete('glpi_plugin_redmine_users', ['redmine_id' => $row['redmine_id']]); + $DB->insert('glpi_plugin_redmine_users', $row); + Toolbox::logInFile('redmine', "Cache de usuários atualizado ao vivo para '{$email}' -> {$row['login']}.\n"); + return $row; + } } return null; @@ -200,6 +216,7 @@ class PluginRedmineConfig extends CommonDBTM { 'status_map' => is_array($status_map) ? $status_map : [], 'glpi_ticket_statuses' => \Ticket::getAllStatusArray(), 'usermap' => $usermap, + 'user_cache_count' => count($redmine_user_options), ]); return true; diff --git a/inc/redmineapi.class.php b/inc/redmineapi.class.php index 71b3e00..886fa29 100644 --- a/inc/redmineapi.class.php +++ b/inc/redmineapi.class.php @@ -393,27 +393,65 @@ class PluginRedmineRedmineapi { ]; } + /** + * Find a single Redmine user by e-mail, live (admin only). + * Used as self-healing fallback when the local cache misses. + * @param string $email + * @return array|null the Redmine user, or null + */ + public static function findUserByEmail($email) { + if (empty($email)) { + return null; + } + $result = self::get("/users.json?name=" . urlencode($email) . "&limit=10"); + foreach (($result['users'] ?? []) as $u) { + if (strcasecmp($u['mail'] ?? '', $email) === 0) { + return $u; + } + } + return null; + } + /** * Refresh the local cache of Redmine users (glpi_plugin_redmine_users). + * Upsert per user — never truncates the table, so a mid-sync failure + * can't leave the cache empty (which silently broke impersonation). * @return int number of users cached, or -1 on API failure */ public static function syncUsers() { global $DB; $users = self::getUsers(); if (empty($users)) { - // Distinguish "no access" from "zero users": getUsers returns [] for both, - // but a working admin key always sees at least itself. Treat [] as failure-safe no-op. + // A working admin key always sees at least itself: [] = API/permission + // failure. Keep the existing cache untouched. + Toolbox::logInFile('redmine', "syncUsers: API não retornou usuários (chave sem admin? indisponível?). Cache mantido.\n"); return -1; } - $DB->delete('glpi_plugin_redmine_users', [1]); + + $seen = []; foreach ($users as $u) { - $DB->insert('glpi_plugin_redmine_users', [ - 'redmine_id' => (int) $u['id'], - 'login' => $u['login'] ?? null, - 'mail' => $u['mail'] ?? null, - 'name' => trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? '')), - ]); + $rid = (int) $u['id']; + $seen[] = $rid; + $row = [ + 'login' => $u['login'] ?? null, + 'mail' => $u['mail'] ?? null, + 'name' => trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? '')), + ]; + $exists = $DB->request(['FROM' => 'glpi_plugin_redmine_users', 'WHERE' => ['redmine_id' => $rid]]); + if (count($exists) > 0) { + $DB->update('glpi_plugin_redmine_users', $row, ['redmine_id' => $rid]); + } else { + $DB->insert('glpi_plugin_redmine_users', $row + ['redmine_id' => $rid]); + } } + + // Remove somente quem não existe mais no Redmine. + foreach ($DB->request(['FROM' => 'glpi_plugin_redmine_users']) as $row) { + if (!in_array((int) $row['redmine_id'], $seen, true)) { + $DB->delete('glpi_plugin_redmine_users', ['id' => $row['id']]); + } + } + return count($users); } diff --git a/setup.php b/setup.php index 4ec09c0..1e1311b 100644 --- a/setup.php +++ b/setup.php @@ -1,6 +1,6 @@ {{ __('Mapeamento de usuários (GLPI → Redmine)', 'redmine') }}
+ {% if user_cache_count|default(0) == 0 %} +
+ + {{ __('Cache de usuários Redmine VAZIO — os lançamentos sairão como admin. Clique em "Sincronizar Metadados".', 'redmine') }} +
+ {% endif %}
{{ __('Usuários com o mesmo e-mail nos dois sistemas são associados automaticamente. Use a tabela abaixo apenas para exceções (e-mails diferentes).', 'redmine') }} +
{{ __('Usuários Redmine em cache:', 'redmine') }} {{ user_cache_count|default(0) }}