feat(audit) : contexte forensique dans le journal d'activité (IP, appareil, device id) (#33)
Auto Tag Develop / tag (push) Successful in 9s

## Contexte

Certains comptes sont **partagés** par plusieurs personnes (ex. compte « Usine »), y compris depuis des smartphones. Le journal d'activité ne stockait que le `username` → impossible de distinguer les intervenants. Cette PR ajoute un **contexte forensique automatique** à chaque entrée du journal.

## Ce qui est ajouté (capté automatiquement, sans friction utilisateur)

- **Adresse IP** de la requête
- **User-Agent brut** (borné à 1024 caractères)
- **Libellé appareil lisible** dérivé du User-Agent : `Type · OS · Navigateur` (ex. `Mobile · Android · Chrome`)
- **Identifiant d'appareil persistant** envoyé par le front (header `X-Device-Id`, stocké en `localStorage`, borné à 64 car.) — distingue les **appareils** derrière un compte partagé

## Implémentation

- `UserAgentParser` (service maison, sans dépendance) — détection ordonnée OS/navigateur, testée
- 4 colonnes **nullable** sur `audit_logs` + migration réversible (pas de backfill, rétro-compatible)
- Capture **centralisée** dans `AuditLogger::log()` via `RequestStack` — aucun processor modifié
- Champs exposés dans l'API lecture (`AuditLogProvider` + DTO TS aligné) via `AuditLogReadRepositoryInterface` (suit le pattern existant des autres read-repos)
- Front : `useDeviceId` + injection du header `X-Device-Id` dans `useApi` (sur toutes les requêtes, SSR-safe)
- `framework.trusted_proxies` documenté (commenté) pour une IP correcte derrière un reverse proxy
- Docs : `doc/audit-logging.md` + `CLAUDE.md`

## Hors périmètre (étapes suivantes)

- **Écran du journal (`audit-logs.vue`) non modifié** — l'affichage des nouvelles colonnes fera l'objet d'une refonte séparée. Les données sont prêtes côté API.
- La doc in-app (`documentation-content.ts`) n'est pas touchée : le journal est un outil caché `ROLE_SUPER_ADMIN` sans article existant ni niveau de doc super-admin.

## À noter pour le déploiement

- L'IP n'est fiable derrière un reverse proxy qu'une fois `framework.trusted_proxies` activé (livré commenté).

## Tests

`OK (249 tests, 533 assertions)` — sortie PHPUnit propre (aucune notice).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #33
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #33.
This commit is contained in:
2026-06-24 11:56:42 +00:00
committed by Autin
parent c119db0b02
commit 832751d1ed
26 changed files with 3467 additions and 308 deletions
+8
View File
@@ -80,6 +80,14 @@ export const useApi = (): ApiClient => {
baseURL,
retry: 0,
credentials: 'include',
onRequest({ options }) {
const deviceId = useDeviceId()
if (deviceId) {
const headers = new Headers(options.headers as HeadersInit | undefined)
headers.set('X-Device-Id', deviceId)
options.headers = headers
}
},
onResponse({ options, response }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (apiOptions?.toast === false) {
+144
View File
@@ -0,0 +1,144 @@
import { ref, computed } from 'vue'
import type { AuditLog } from '~/services/dto/audit-log'
import { fetchAuditLogs, type AuditLogFilters } from '~/services/audit-logs'
type Range = { start: string, end: string } | null
export const useAuditLogsList = () => {
const items = ref<AuditLog[]>([])
const total = ref(0)
const page = ref(1)
const perPage = ref(10)
const loading = ref(false)
const filterOpen = ref(false)
// Applied filters (drive the fetch)
const appliedEmployee = ref('')
const appliedRange = ref<Range>(null)
const appliedEntityTypes = ref<string[]>([])
const appliedActions = ref<string[]>([])
const appliedUsername = ref('')
const appliedIp = ref('')
const appliedDevice = ref('')
// Draft filters (edited inside the drawer)
const draftEmployee = ref('')
const draftRange = ref<Range>(null)
const draftEntityTypes = ref<string[]>([])
const draftActions = ref<string[]>([])
const draftUsername = ref('')
const draftIp = ref('')
const draftDevice = ref('')
const activeFilterCount = computed(() => {
let n = 0
if (appliedEmployee.value.trim() !== '') n++
if (appliedRange.value?.start || appliedRange.value?.end) n++
if (appliedEntityTypes.value.length > 0) n++
if (appliedActions.value.length > 0) n++
if (appliedUsername.value.trim() !== '') n++
if (appliedIp.value.trim() !== '') n++
if (appliedDevice.value.trim() !== '') n++
return n
})
const buildFilters = (): AuditLogFilters => ({
employee: appliedEmployee.value.trim() || undefined,
from: appliedRange.value?.start || undefined,
to: appliedRange.value?.end || undefined,
entityType: appliedEntityTypes.value.length > 0 ? [...appliedEntityTypes.value] : undefined,
action: appliedActions.value.length > 0 ? [...appliedActions.value] : undefined,
username: appliedUsername.value.trim() || undefined,
ip: appliedIp.value.trim() || undefined,
device: appliedDevice.value.trim() || undefined,
page: page.value,
perPage: perPage.value,
})
// Race guard: only the latest request may commit its result.
let requestSeq = 0
const load = async () => {
const seq = ++requestSeq
loading.value = true
try {
const result = await fetchAuditLogs(buildFilters())
if (seq !== requestSeq) return
items.value = result.items
total.value = result.total
page.value = result.page
perPage.value = result.perPage
} finally {
if (seq === requestSeq) loading.value = false
}
}
const init = async () => {
await load()
}
const goToPage = (n: number) => {
page.value = n
load()
}
const setPerPage = (n: number) => {
perPage.value = n
page.value = 1
load()
}
const openFilters = () => {
draftEmployee.value = appliedEmployee.value
draftRange.value = appliedRange.value ? { ...appliedRange.value } : null
draftEntityTypes.value = [...appliedEntityTypes.value]
draftActions.value = [...appliedActions.value]
draftUsername.value = appliedUsername.value
draftIp.value = appliedIp.value
draftDevice.value = appliedDevice.value
filterOpen.value = true
}
const applyFilters = () => {
appliedEmployee.value = draftEmployee.value
appliedRange.value = draftRange.value ? { ...draftRange.value } : null
appliedEntityTypes.value = [...draftEntityTypes.value]
appliedActions.value = [...draftActions.value]
appliedUsername.value = draftUsername.value
appliedIp.value = draftIp.value
appliedDevice.value = draftDevice.value
page.value = 1
filterOpen.value = false
load()
}
const resetFilters = () => {
draftEmployee.value = ''
draftRange.value = null
draftEntityTypes.value = []
draftActions.value = []
draftUsername.value = ''
draftIp.value = ''
draftDevice.value = ''
appliedEmployee.value = ''
appliedRange.value = null
appliedEntityTypes.value = []
appliedActions.value = []
appliedUsername.value = ''
appliedIp.value = ''
appliedDevice.value = ''
page.value = 1
load() // drawer stays open
}
const toggle = (arr: typeof draftEntityTypes, value: string, selected: boolean) => {
arr.value = selected ? [...arr.value, value] : arr.value.filter(v => v !== value)
}
const toggleEntityType = (value: string, selected: boolean) => toggle(draftEntityTypes, value, selected)
const toggleAction = (value: string, selected: boolean) => toggle(draftActions, value, selected)
return {
items, total, page, perPage, loading, filterOpen, activeFilterCount,
draftEmployee, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice,
init, goToPage, setPerPage, openFilters, applyFilters, resetFilters, toggleEntityType, toggleAction,
}
}
+28
View File
@@ -0,0 +1,28 @@
// Stable per-device identifier used to add forensic context to audit logs.
// Persisted in localStorage so the same browser/device reuses it across sessions.
// NOTE: this identifies a device/browser, not a human — on a shared kiosk every
// user of the same browser shares one id (intended: it distinguishes devices).
const STORAGE_KEY = 'sirh-device-id'
let cached: string | null = null
export const useDeviceId = (): string | null => {
if (!import.meta.client) {
return null
}
if (cached) {
return cached
}
try {
let id = localStorage.getItem(STORAGE_KEY)
if (!id) {
id = crypto.randomUUID()
localStorage.setItem(STORAGE_KEY, id)
}
cached = id
return id
} catch {
// localStorage unavailable (private mode, disabled) — degrade gracefully.
return null
}
}