feat(persons): identify impersonated identities

This commit is contained in:
grayTerminal-sh 2026-07-22 22:29:59 +02:00
parent 18e59cc5e6
commit a6ec67f38d
20 changed files with 321 additions and 11 deletions

View file

@ -1,7 +1,7 @@
/******************************************************************************
* Labfy Investigation
*
* Extensions idempotentes du schéma SQLite courant V5
* Extensions idempotentes du schéma SQLite courant V6
******************************************************************************/
/*

38
database/schema_v6.sql Normal file
View file

@ -0,0 +1,38 @@
/******************************************************************************
* Labfy Investigation
*
* Migration du schéma SQLite V5 vers V6 : catégorie identité usurpée
******************************************************************************/
ALTER TABLE person_roles RENAME TO person_roles_v5;
CREATE TABLE person_roles
(
entity_id TEXT PRIMARY KEY,
role TEXT NOT NULL DEFAULT 'uncategorized',
updated_at TEXT NOT NULL,
FOREIGN KEY (entity_id)
REFERENCES entites(id)
ON UPDATE CASCADE
ON DELETE CASCADE,
CHECK (role IN
(
'uncategorized',
'alleged_scammer',
'victim',
'witness',
'suspect',
'related_person',
'impersonated_identity'
)),
CHECK (length(updated_at) = 20)
);
INSERT INTO person_roles(entity_id, role, updated_at)
SELECT entity_id, role, updated_at FROM person_roles_v5;
DROP TABLE person_roles_v5;
CREATE INDEX idx_person_roles_role ON person_roles(role);

View file

@ -1161,11 +1161,11 @@ compte via `preuve_entites`. La création des deux lignes et de cette liaison
est transactionnelle : aucun nœud incomplet n'est conservé si une étape
échoue.
### Rôles d'enquête des personnes — schéma V5
### Rôles d'enquête des personnes — schémas V5 et V6
La table `person_roles` associe une entité de type personne à une catégorie
d'enquête contrôlée : scammer présumé, victime, témoin, suspect, personne liée
ou non catégorisée. La clé étrangère avec suppression en cascade évite les
d'enquête contrôlée : scammer présumé, victime, témoin, suspect, personne liée,
identité usurpée ou non catégorisée. La clé étrangère avec suppression en cascade évite les
rôles orphelins. Le code stable est persisté ; le libellé et la couleur restent
des choix de présentation.

View file

@ -42,5 +42,15 @@ gboolean person_entity_service_update_role(Database *database,
*/
gboolean person_entity_service_update_confidence(Database *database,
const char *entity_identifier, gint confidence, GError **error);
/**
* @brief Enregistre le nom affiché d'une personne existante.
* @param database Connexion ouverte empruntée.
* @param entity_identifier UUID de la personne.
* @param display_name Nouveau nom non vide.
* @param error Destination facultative d'une erreur.
* @return TRUE lorsque la modification est validée.
*/
gboolean person_entity_service_update_display_name(Database *database,
const char *entity_identifier, const char *display_name, GError **error);
G_END_DECLS
#endif

View file

@ -84,6 +84,9 @@ bool schema_install_v4(
/** @brief Installe la migration des rôles de personnes du schéma V5. */
bool schema_install_v5(Database *database);
/** @brief Installe la catégorie d'identité usurpée du schéma V6. */
bool schema_install_v6(Database *database);
/**
* @brief Garantit la présence des extensions du schéma courant V2.
*

View file

@ -37,7 +37,8 @@ typedef enum
PERSON_ROLE_VICTIM,
PERSON_ROLE_WITNESS,
PERSON_ROLE_SUSPECT,
PERSON_ROLE_RELATED_PERSON
PERSON_ROLE_RELATED_PERSON,
PERSON_ROLE_IMPERSONATED_IDENTITY
} PersonRole;
/**

View file

@ -170,6 +170,9 @@ typedef void (*MainWindowOsintActionCallback)(
const char *target_value,
gpointer user_data
);
/** @brief Callback appelé pour modifier le nom affiché d'une personne. */
typedef void (*MainWindowPersonNameCallback)(const char *entity_identifier,
const char *display_name, gpointer user_data);
/**
* @brief Définit le callback de vérification d'une preuve.
@ -287,6 +290,9 @@ void main_window_set_osint_action_callback(
MainWindowOsintActionCallback callback,
gpointer user_data
);
/** @brief Définit le callback de modification du nom affiché. */
void main_window_set_person_name_callback(MainWindow *main_window,
MainWindowPersonNameCallback callback, gpointer user_data);
/**
* @brief Définit le callback du bouton « Nouvelle enquête ».

View file

@ -60,6 +60,13 @@ typedef void (*EntityDetailsPanelPersonConfidenceCallback)(
gpointer user_data
);
/** @brief Callback appelé pour enregistrer le nom affiché d'une personne. */
typedef void (*EntityDetailsPanelPersonNameCallback)(
const char *entity_identifier,
const char *display_name,
gpointer user_data
);
/**
* @brief Crée un volet de détails fermé.
*
@ -155,6 +162,13 @@ void entity_details_panel_set_person_confidence_callback(
gpointer user_data
);
/** @brief Définit le callback de modification du nom affiché. */
void entity_details_panel_set_person_name_callback(
EntityDetailsPanel *details_panel,
EntityDetailsPanelPersonNameCallback callback,
gpointer user_data
);
/**
* @brief Indique si le volet est actuellement ouvert.
*

View file

@ -114,6 +114,9 @@ typedef void (*WorkspaceOsintActionCallback)(
const char *target_value,
gpointer user_data
);
/** @brief Callback appelé pour modifier le nom affiché d'une personne. */
typedef void (*WorkspacePersonNameCallback)(const char *entity_identifier,
const char *display_name, gpointer user_data);
/** @brief Callback appelé pour modifier les métadonnées d'une preuve. */
typedef void (*WorkspaceEditEvidenceCallback)(
@ -365,6 +368,9 @@ void workspace_set_person_confidence_callback(Workspace *workspace,
void workspace_set_graph_loading(
Workspace *workspace
);
/** @brief Définit le callback de modification du nom affiché. */
void workspace_set_person_name_callback(Workspace *workspace,
WorkspacePersonNameCallback callback, gpointer user_data);
/**
* @brief Affiche le graphe chargé avec sa disposition persistée.

View file

@ -5033,6 +5033,35 @@ static void application_on_person_confidence_changed(
investigation_project_get_database_path(project));
}
/** @brief Enregistre le nom affiché choisi dans la fiche personne. */
static void application_on_person_name_changed(const char *entity_identifier,
const char *display_name, gpointer user_data)
{
Application *application = user_data;
Database *database = NULL;
const InvestigationProject *project = NULL;
GError *error = NULL;
if (application == NULL || application->session == NULL) return;
database = investigation_session_get_database(application->session);
project = investigation_session_get_project(application->session);
if (!person_entity_service_update_display_name(database, entity_identifier,
display_name, &error))
{
application_present_error(application, "Nom non enregistré",
error != NULL ? error->message :
"Impossible de modifier le nom affiché de cette personne.");
g_clear_error(&error);
return;
}
g_free(application->pending_entity_selection_identifier);
application->pending_entity_selection_identifier =
g_strdup(entity_identifier);
main_window_set_status(application->main_window,
"Nom enregistré. Actualisation du graphe…");
application_start_graph_loading(application,
investigation_project_get_database_path(project));
}
/** @brief Libère le contexte d'édition d'une relation. */
static void application_edit_relation_context_free(
ApplicationEditRelationContext *context)
@ -6705,6 +6734,8 @@ static void application_on_activate(
application_on_person_role_changed, application);
main_window_set_person_confidence_callback(application->main_window,
application_on_person_confidence_changed, application);
main_window_set_person_name_callback(application->main_window,
application_on_person_name_changed, application);
main_window_set_osint_action_callback(
application->main_window,

View file

@ -17,6 +17,58 @@ static gboolean person_entity_service_status_valid(const char *status)
g_strcmp0(status, "confirmed") == 0;
}
gboolean person_entity_service_update_display_name(Database *database,
const char *entity_identifier, const char *display_name, GError **error)
{
static const char sql[] =
"UPDATE entites SET label=trim(?), updated_at=? WHERE id=? AND "
"type_id=(SELECT id FROM types_entite WHERE code='person');";
DatabaseStatement *statement = NULL;
GDateTime *now = NULL;
char *timestamp = NULL;
char *normalized = NULL;
gboolean active = FALSE, success = FALSE;
g_return_val_if_fail(error == NULL || *error == NULL, FALSE);
normalized = display_name != NULL ? g_strdup(display_name) : NULL;
if (normalized != NULL) g_strstrip(normalized);
if (database == NULL || entity_identifier == NULL ||
!g_uuid_string_is_valid(entity_identifier) || normalized == NULL ||
normalized[0] == '\0')
{
g_set_error_literal(error, g_quark_from_static_string(
"person-entity-service-error"), 6,
"Le nom affiché de la personne est invalide.");
g_free(normalized);
return FALSE;
}
now = g_date_time_new_now_utc();
timestamp = now != NULL ? g_date_time_format(now,
"%Y-%m-%dT%H:%M:%SZ") : NULL;
if (timestamp == NULL || !database_transaction_begin(database))
goto cleanup;
active = TRUE;
statement = database_statement_prepare(database, sql);
if (statement == NULL || !database_statement_bind_text(statement, 1,
normalized) || !database_statement_bind_text(statement, 2,
timestamp) || !database_statement_bind_text(statement, 3,
entity_identifier) || database_statement_step(statement) !=
DATABASE_STATEMENT_STEP_DONE)
goto cleanup;
database_statement_finalize(statement); statement = NULL;
if (!database_transaction_commit(database)) goto cleanup;
active = FALSE; success = TRUE;
cleanup:
database_statement_finalize(statement);
if (!success && active) database_transaction_rollback(database);
if (!success && error != NULL && *error == NULL)
g_set_error_literal(error, g_quark_from_static_string(
"person-entity-service-error"), 7,
"Le nom affiché n'a pas pu être enregistré.");
g_free(normalized); g_free(timestamp);
g_clear_pointer(&now, g_date_time_unref);
return success;
}
gboolean person_entity_service_update_confidence(Database *database,
const char *entity_identifier, gint confidence, GError **error)
{

View file

@ -16,12 +16,12 @@
/**
* @brief Version actuelle du schéma SQLite.
*/
#define DATABASE_SCHEMA_VERSION_CURRENT 5
#define DATABASE_SCHEMA_VERSION_CURRENT 6
/**
* @brief Version actuelle sous forme textuelle pour metadata.
*/
#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "5"
#define DATABASE_SCHEMA_VERSION_CURRENT_TEXT "6"
/**
* @brief Nom de l'application enregistré dans les métadonnées.
@ -730,6 +730,24 @@ rollback:
return false;
}
/** @brief Applique atomiquement la migration du schéma V5 vers V6. */
static bool database_migrate_v5_to_v6(Database *database)
{
bool transaction_started = false;
if (database == NULL || !database_transaction_begin(database))
return false;
transaction_started = true;
if (!schema_install_v6(database) ||
!database_update_schema_version(database, "6") ||
!database_transaction_commit(database))
goto rollback;
return true;
rollback:
if (transaction_started && !database_transaction_rollback(database))
g_warning("Impossible dannuler la migration SQLite V5 vers V6.");
return false;
}
/**
* @brief Garantit atomiquement la présence des extensions du schéma courant.
*/
@ -934,6 +952,12 @@ bool database_migrate_to_latest(
schema_version = 5;
break;
case 5:
if (!database_migrate_v5_to_v6(database))
return false;
schema_version = 6;
break;
default:
database_set_error(
database,
@ -1060,6 +1084,11 @@ bool database_initialize(
goto rollback;
}
if (!schema_install_v6(database))
{
goto rollback;
}
if (!schema_ensure_current(
database
))

View file

@ -221,6 +221,12 @@ bool schema_install_v5(Database *database)
"la migration SQLite V5");
}
bool schema_install_v6(Database *database)
{
return schema_execute_file(database, "database/schema_v6.sql",
"la migration SQLite V6");
}
bool schema_ensure_current(
Database *database
)

View file

@ -613,6 +613,8 @@ const char *person_role_to_code(PersonRole role)
case PERSON_ROLE_WITNESS: return "witness";
case PERSON_ROLE_SUSPECT: return "suspect";
case PERSON_ROLE_RELATED_PERSON: return "related_person";
case PERSON_ROLE_IMPERSONATED_IDENTITY:
return "impersonated_identity";
default: return NULL;
}
}
@ -626,6 +628,8 @@ PersonRole person_role_from_code(const char *code)
if (g_strcmp0(code, "suspect") == 0) return PERSON_ROLE_SUSPECT;
if (g_strcmp0(code, "related_person") == 0)
return PERSON_ROLE_RELATED_PERSON;
if (g_strcmp0(code, "impersonated_identity") == 0)
return PERSON_ROLE_IMPERSONATED_IDENTITY;
return PERSON_ROLE_UNCATEGORIZED;
}
@ -638,6 +642,7 @@ const char *person_role_get_label(PersonRole role)
case PERSON_ROLE_WITNESS: return "Témoin";
case PERSON_ROLE_SUSPECT: return "Suspect";
case PERSON_ROLE_RELATED_PERSON: return "Personne liée";
case PERSON_ROLE_IMPERSONATED_IDENTITY: return "Identité usurpée";
case PERSON_ROLE_UNCATEGORIZED:
default: return "Non catégorisé";
}

View file

@ -135,6 +135,8 @@ struct MainWindow
gpointer person_role_user_data;
MainWindowPersonConfidenceCallback person_confidence_callback;
gpointer person_confidence_user_data;
MainWindowPersonNameCallback person_name_callback;
gpointer person_name_user_data;
MainWindowOsintActionCallback
osint_action_callback;
@ -551,6 +553,16 @@ static void main_window_on_person_confidence_changed(
main_window->person_confidence_user_data);
}
/** @brief Relaie la modification du nom affiché d'une personne. */
static void main_window_on_person_name_changed(const char *entity_identifier,
const char *display_name, gpointer user_data)
{
MainWindow *main_window = user_data;
if (main_window != NULL && main_window->person_name_callback != NULL)
main_window->person_name_callback(entity_identifier, display_name,
main_window->person_name_user_data);
}
/**
* @brief Relaie la demande de vérification provenant du Workspace.
*/
@ -895,6 +907,8 @@ MainWindow *main_window_new(
main_window_on_person_role_changed, main_window);
workspace_set_person_confidence_callback(main_window->workspace,
main_window_on_person_confidence_changed, main_window);
workspace_set_person_name_callback(main_window->workspace,
main_window_on_person_name_changed, main_window);
workspace_set_osint_action_callback(
main_window->workspace,
@ -1718,6 +1732,14 @@ void main_window_set_person_confidence_callback(MainWindow *main_window,
main_window->person_confidence_user_data = user_data;
}
void main_window_set_person_name_callback(MainWindow *main_window,
MainWindowPersonNameCallback callback, gpointer user_data)
{
if (main_window == NULL) return;
main_window->person_name_callback = callback;
main_window->person_name_user_data = user_data;
}
void main_window_set_quit_callback(
MainWindow *main_window,
MainWindowQuitCallback callback,

View file

@ -33,6 +33,8 @@ struct EntityDetailsPanel
GtkDropDown *person_role_dropdown;
GtkSpinButton *person_confidence_spin;
GtkWidget *person_confidence_button;
GtkEntry *person_name_entry;
GtkWidget *person_name_button;
char *selected_entity_identifier;
@ -50,8 +52,26 @@ struct EntityDetailsPanel
gboolean updating_person_role;
EntityDetailsPanelPersonConfidenceCallback person_confidence_callback;
gpointer person_confidence_user_data;
EntityDetailsPanelPersonNameCallback person_name_callback;
gpointer person_name_user_data;
};
/** @brief Relaie le nouveau nom affiché explicitement enregistré. */
static void entity_details_panel_on_person_name_clicked(GtkButton *button,
gpointer user_data)
{
EntityDetailsPanel *details_panel = user_data;
const char *name = NULL;
(void) button;
if (details_panel == NULL || details_panel->person_name_callback == NULL ||
details_panel->selected_entity_identifier == NULL) return;
name = gtk_editable_get_text(GTK_EDITABLE(details_panel->person_name_entry));
if (name == NULL || name[0] == '\0') return;
details_panel->person_name_callback(
details_panel->selected_entity_identifier, name,
details_panel->person_name_user_data);
}
/** @brief Relaie la confiance explicitement enregistrée par l'utilisateur. */
static void entity_details_panel_on_person_confidence_clicked(
GtkButton *button, gpointer user_data)
@ -80,7 +100,7 @@ static void entity_details_panel_on_person_role_changed(GObject *object,
details_panel->selected_entity_identifier == NULL)
return;
selected = gtk_drop_down_get_selected(details_panel->person_role_dropdown);
if (selected > PERSON_ROLE_RELATED_PERSON) return;
if (selected > PERSON_ROLE_IMPERSONATED_IDENTITY) return;
details_panel->person_role_callback(
details_panel->selected_entity_identifier, (PersonRole) selected,
details_panel->person_role_user_data);
@ -744,7 +764,7 @@ EntityDetailsPanel *entity_details_panel_new(void)
details_panel->person_role_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
person_role_labels = gtk_string_list_new(NULL);
for (guint role = PERSON_ROLE_UNCATEGORIZED;
role <= PERSON_ROLE_RELATED_PERSON; role++)
role <= PERSON_ROLE_IMPERSONATED_IDENTITY; role++)
gtk_string_list_append(person_role_labels,
person_role_get_label((PersonRole) role));
details_panel->person_role_dropdown = GTK_DROP_DOWN(
@ -764,12 +784,25 @@ EntityDetailsPanel *entity_details_panel_new(void)
GTK_WIDGET(details_panel->person_confidence_spin));
gtk_box_append(GTK_BOX(details_panel->person_role_box),
details_panel->person_confidence_button);
details_panel->person_name_entry = GTK_ENTRY(gtk_entry_new());
gtk_entry_set_placeholder_text(details_panel->person_name_entry,
"Nom réel ou nom affiché");
details_panel->person_name_button = gtk_button_new_with_label(
"Enregistrer le nom");
gtk_box_append(GTK_BOX(details_panel->person_role_box),
gtk_label_new("Nom affiché dans le graphe"));
gtk_box_append(GTK_BOX(details_panel->person_role_box),
GTK_WIDGET(details_panel->person_name_entry));
gtk_box_append(GTK_BOX(details_panel->person_role_box),
details_panel->person_name_button);
gtk_box_append(GTK_BOX(details_box), details_panel->person_role_box);
g_signal_connect(details_panel->person_role_dropdown, "notify::selected",
G_CALLBACK(entity_details_panel_on_person_role_changed), details_panel);
g_signal_connect(details_panel->person_confidence_button, "clicked",
G_CALLBACK(entity_details_panel_on_person_confidence_clicked),
details_panel);
g_signal_connect(details_panel->person_name_button, "clicked",
G_CALLBACK(entity_details_panel_on_person_name_clicked), details_panel);
if (details_panel->entity_value_label == NULL ||
details_panel->entity_type_label == NULL ||
@ -896,6 +929,9 @@ void entity_details_panel_set_entity(
(guint) entity_record_get_person_role(entity_record));
gtk_spin_button_set_value(details_panel->person_confidence_spin,
entity_record_get_confidence(entity_record));
gtk_editable_set_text(GTK_EDITABLE(details_panel->person_name_entry),
entity_record_get_label(entity_record) != NULL
? entity_record_get_label(entity_record) : "");
details_panel->updating_person_role = FALSE;
entity_details_panel_update_add_relation_button(
@ -1161,6 +1197,15 @@ void entity_details_panel_set_person_confidence_callback(
details_panel->person_confidence_user_data = user_data;
}
void entity_details_panel_set_person_name_callback(
EntityDetailsPanel *details_panel,
EntityDetailsPanelPersonNameCallback callback, gpointer user_data)
{
if (details_panel == NULL) return;
details_panel->person_name_callback = callback;
details_panel->person_name_user_data = user_data;
}
gboolean entity_details_panel_is_open(
const EntityDetailsPanel *details_panel
)

View file

@ -3343,6 +3343,8 @@ static void investigation_graph_view_draw_entity(
cairo_set_source_rgb(cairo_context, 0.62, 0.32, 0.08); break;
case PERSON_ROLE_RELATED_PERSON:
cairo_set_source_rgb(cairo_context, 0.38, 0.20, 0.52); break;
case PERSON_ROLE_IMPERSONATED_IDENTITY:
cairo_set_source_rgb(cairo_context, 0.61, 0.35, 0.71); break;
case PERSON_ROLE_UNCATEGORIZED:
default:
cairo_set_source_rgb(cairo_context, 0.25, 0.27, 0.31); break;

View file

@ -138,6 +138,8 @@ struct Workspace
gpointer person_role_user_data;
WorkspacePersonConfidenceCallback person_confidence_callback;
gpointer person_confidence_user_data;
WorkspacePersonNameCallback person_name_callback;
gpointer person_name_user_data;
WorkspaceOsintActionCallback
osint_action_callback;
@ -894,6 +896,16 @@ static void workspace_on_person_confidence_changed(
workspace->person_confidence_user_data);
}
/** @brief Relaie la modification du nom affiché d'une personne. */
static void workspace_on_person_name_changed(const char *entity_identifier,
const char *display_name, gpointer user_data)
{
Workspace *workspace = user_data;
if (workspace != NULL && workspace->person_name_callback != NULL)
workspace->person_name_callback(entity_identifier, display_name,
workspace->person_name_user_data);
}
/**
* @brief Désélectionne le nœud lorsque le volet est fermé manuellement.
*/
@ -1816,6 +1828,9 @@ Workspace *workspace_new(void)
entity_details_panel_set_person_confidence_callback(
workspace->entity_details_panel,
workspace_on_person_confidence_changed, workspace);
entity_details_panel_set_person_name_callback(
workspace->entity_details_panel, workspace_on_person_name_changed,
workspace);
gtk_overlay_set_child(
GTK_OVERLAY(
@ -3281,6 +3296,14 @@ void workspace_set_person_confidence_callback(Workspace *workspace,
workspace->person_confidence_user_data = user_data;
}
void workspace_set_person_name_callback(Workspace *workspace,
WorkspacePersonNameCallback callback, gpointer user_data)
{
if (workspace == NULL) return;
workspace->person_name_callback = callback;
workspace->person_name_user_data = user_data;
}
void workspace_reset_graph_layout(
Workspace *workspace
)

View file

@ -525,7 +525,7 @@ static void test_database_initialize_valid_database(void)
"FROM investigation;"
);
assert(strcmp(schema_version, "5") == 0);
assert(strcmp(schema_version, "6") == 0);
test_database_assert_table_exists(database, "osint_executions");
test_database_assert_table_exists(database, "osint_execution_entities");
test_database_assert_table_exists(database, "osint_execution_relations");
@ -989,7 +989,7 @@ static void test_database_migrate_v1_to_v2(void)
assert(
strcmp(
schema_version,
"5"
"6"
) == 0
);

View file

@ -44,6 +44,23 @@ static void test_person_entity_create(void)
record = entity_dao_find_by_identifier(dao, identifier, &error);
assert(record != NULL && error == NULL);
assert(entity_record_get_person_role(record) == PERSON_ROLE_VICTIM);
entity_record_free(record); record = NULL;
assert(person_entity_service_update_role(database, identifier,
PERSON_ROLE_IMPERSONATED_IDENTITY, &error));
record = entity_dao_find_by_identifier(dao, identifier, &error);
assert(record != NULL && error == NULL);
assert(entity_record_get_person_role(record) ==
PERSON_ROLE_IMPERSONATED_IDENTITY);
assert(strcmp(person_role_to_code(PERSON_ROLE_IMPERSONATED_IDENTITY),
"impersonated_identity") == 0);
assert(strcmp(person_role_get_label(PERSON_ROLE_IMPERSONATED_IDENTITY),
"Identité usurpée") == 0);
assert(person_entity_service_update_display_name(database, identifier,
"Vraie identité", &error));
entity_record_free(record); record = NULL;
record = entity_dao_find_by_identifier(dao, identifier, &error);
assert(record != NULL && error == NULL);
assert(strcmp(entity_record_get_label(record), "Vraie identité") == 0);
entity_record_free(record); entity_dao_free(dao); database_close(database);
assert(g_remove(path) == 0); assert(g_rmdir(directory) == 0);
g_free(identifier); g_free(path); g_free(directory);