feat: resolução de usuários self-healing + syncUsers upsert + fallback logado (1.6.0)

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 <noreply@anthropic.com>
This commit is contained in:
Gemini 2026-07-02 12:56:49 -03:00
parent 1b790354a3
commit b8f89cda8d
6 changed files with 92 additions and 11 deletions

View file

@ -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

View file

@ -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();

View file

@ -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;

View file

@ -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'],
$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);
}

View file

@ -1,6 +1,6 @@
<?php
define('PLUGIN_REDMINE_VERSION', '1.5.7');
define('PLUGIN_REDMINE_VERSION', '1.6.0');
/**
* Load the Mindplace License class if the autoloader has not run yet.

View file

@ -91,8 +91,15 @@
<h4 class="card-title mb-0"><i class="ti ti-user-cog me-1"></i>{{ __('Mapeamento de usuários (GLPI → Redmine)', 'redmine') }}</h4>
</div>
<div class="card-body">
{% if user_cache_count|default(0) == 0 %}
<div class="alert alert-warning">
<i class="ti ti-alert-triangle me-1"></i>
{{ __('Cache de usuários Redmine VAZIO — os lançamentos sairão como admin. Clique em "Sincronizar Metadados".', 'redmine') }}
</div>
{% endif %}
<div class="alert alert-info">
{{ __('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') }}
<br><small>{{ __('Usuários Redmine em cache:', 'redmine') }} <strong>{{ user_cache_count|default(0) }}</strong></small>
</div>
<table class="table table-sm align-middle">