Auditoria i Logging
Tipus: Document d'Arquitectura
Versió: 1.0
Actualitzat: 2024-12
Estat: Normatiu
Resum
Aquest document defineix l'estratègia de logging i auditoria per a LDP. Cobreix:
- Què loguejar (i què NO)
- Estructura de logs
- Correlation IDs per traçabilitat
- Auditoria d'operacions sensibles
1. Principis de Logging
1.1. Logging ≠ Debugging
| Propòsit | Descripció | Nivell |
|---|---|---|
| Observabilitat | Entendre què passa al sistema | INFO |
| Auditoria | Registrar QUI va fer QUÈ QUAN | INFO/WARN |
| Troubleshooting | Diagnosticar problemes | WARN/ERROR |
| Debugging | Detalls per desenvolupament | DEBUG (mai en prod) |
1.2. Regla d'Or de Seguretat
Mai Loguejar Secrets
- ❌ Passwords
- ❌ Tokens (JWT, refresh, API keys)
- ❌ Credencials de BD
- ❌ PII complet (email, telèfon, DNI)
- ❌ Dades de targetes
- ❌ Session IDs complets
2. Què Loguejar
2.1. Operacions Obligatòries a Loguejar
| Operació | Nivell | Dades a Incloure |
|---|---|---|
| Login exitós | INFO | userId (no password) |
| Login fallit | WARN | intent (email parcialment emmascarada), IP, motiu |
| Logout | INFO | userId |
| Token refresh | DEBUG | userId |
| Accés denegat | WARN | userId, recurs, motiu |
| Creació d'entitat | INFO | entityType, entityId, userId |
| Modificació d'entitat | INFO | entityType, entityId, userId, camps canviats (noms, no valors sensibles) |
| Eliminació d'entitat | WARN | entityType, entityId, userId |
| Canvi de rol | WARN | targetUserId, oldRole, newRole, byUserId |
| Ownership claim/transfer | WARN | resourceType, resourceId, fromUserId, toUserId |
| Error 5xx | ERROR | exception, stack trace, context |
2.2. Operacions Opcionals (DEBUG)
- Queries a BD (amb paràmetres emmascarats)
- Crides a APIs externes
- Temps d'execució de mètodes
2.3. Què NO Loguejar
// ❌ MAI
log.info("User logged in with password: {}", password);
log.debug("JWT token: {}", token);
log.info("Processing user email: {}", email); // PII
// ✅ CORRECTE
log.info("User logged in: userId={}", userId);
log.debug("JWT token issued for userId={}", userId);
log.info("Processing user: userId={}", userId);
3. Estructura de Logs
3.1. Format JSON Estructurat
Configuració Logback per producció:
<!-- logback-spring.xml -->
<configuration>
<springProfile name="prod">
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>correlationId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
<includeMdcKeyName>requestPath</includeMdcKeyName>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="JSON" />
</root>
</springProfile>
<springProfile name="dev">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{correlationId}] %msg%n</pattern>
</encoder>
</appender>
<root level="DEBUG">
<appender-ref ref="CONSOLE" />
</root>
</springProfile>
</configuration>
3.2. Exemple de Log JSON
{
"@timestamp": "2024-12-15T10:30:45.123Z",
"level": "INFO",
"logger": "c.l.s.ChoreographerService",
"message": "Choreographer created",
"correlationId": "abc123-def456-789",
"userId": 42,
"entityType": "Choreographer",
"entityId": 156,
"requestPath": "/api/choreographers",
"duration_ms": 45
}
4. Correlation ID
4.1. Propòsit
El Correlation ID permet traçar una petició a través de totes les capes i serveis:
Client Request
│
▼ correlationId: abc-123
┌─────────────────────┐
│ Controller │ log: [abc-123] Received request
├─────────────────────┤
│ Service │ log: [abc-123] Processing...
├─────────────────────┤
│ Repository │ log: [abc-123] Query executed
├─────────────────────┤
│ External API │ header: X-Correlation-Id: abc-123
└─────────────────────┘
4.2. Implementació
Filter per Generar/Propagar
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorrelationIdFilter extends OncePerRequestFilter {
private static final String CORRELATION_ID_HEADER = "X-Correlation-Id";
private static final String CORRELATION_ID_MDC_KEY = "correlationId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
try {
String correlationId = request.getHeader(CORRELATION_ID_HEADER);
if (correlationId == null || correlationId.isBlank()) {
correlationId = UUID.randomUUID().toString();
}
MDC.put(CORRELATION_ID_MDC_KEY, correlationId);
response.setHeader(CORRELATION_ID_HEADER, correlationId);
chain.doFilter(request, response);
} finally {
MDC.remove(CORRELATION_ID_MDC_KEY);
}
}
}
Propagació a APIs Externes
@Component
public class ExternalApiClient {
private final RestTemplate restTemplate;
public SpotifyTrack fetchTrack(String trackId) {
String correlationId = MDC.get("correlationId");
HttpHeaders headers = new HttpHeaders();
headers.set("X-Correlation-Id", correlationId);
HttpEntity<?> entity = new HttpEntity<>(headers);
return restTemplate.exchange(
spotifyApiUrl + "/tracks/" + trackId,
HttpMethod.GET,
entity,
SpotifyTrack.class
).getBody();
}
}
Propagació a Threads Asíncrons
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setTaskDecorator(new MdcTaskDecorator());
executor.initialize();
return executor;
}
}
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}
5. Context de Request
5.1. Afegir Informació d'Usuari
@Component
public class UserContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof UserDetails user) {
MDC.put("userId", String.valueOf(((User) user).getId()));
}
MDC.put("requestPath", request.getRequestURI());
MDC.put("requestMethod", request.getMethod());
MDC.put("clientIp", getClientIp(request));
chain.doFilter(request, response);
} finally {
MDC.remove("userId");
MDC.remove("requestPath");
MDC.remove("requestMethod");
MDC.remove("clientIp");
}
}
private String getClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
6. Auditoria d'Operacions Sensibles
6.1. Servei d'Auditoria
@Service
@RequiredArgsConstructor
public class AuditService {
private static final Logger auditLog = LoggerFactory.getLogger("AUDIT");
public void logSecurityEvent(SecurityEventType type, Long userId, String details) {
auditLog.info("type={} userId={} details={}",
type, userId, sanitize(details));
}
public void logEntityChange(String entityType, Long entityId,
ChangeType changeType, Long userId) {
auditLog.info("entityType={} entityId={} changeType={} userId={}",
entityType, entityId, changeType, userId);
}
public void logAccessDenied(Long userId, String resource, String reason) {
auditLog.warn("ACCESS_DENIED userId={} resource={} reason={}",
userId, resource, reason);
}
private String sanitize(String input) {
if (input == null) return null;
// Eliminar newlines per evitar log injection
return input.replaceAll("[\r\n]", " ");
}
}
public enum SecurityEventType {
LOGIN_SUCCESS,
LOGIN_FAILED,
LOGOUT,
PASSWORD_CHANGED,
ROLE_CHANGED,
ACCOUNT_LOCKED,
ACCOUNT_UNLOCKED,
TOKEN_REVOKED
}
public enum ChangeType {
CREATE,
UPDATE,
DELETE,
SOFT_DELETE,
RESTORE
}
6.2. Ús a Services
@Service
@RequiredArgsConstructor
public class ChoreographerService {
private final AuditService auditService;
@Transactional
public void delete(Long id) {
Choreographer entity = repo.findById(id).orElseThrow();
Long userId = authUtil.getCurrentUserId();
// Soft delete
entity.setDeletedAt(Instant.now());
repo.save(entity);
// Audit
auditService.logEntityChange("Choreographer", id, ChangeType.SOFT_DELETE, userId);
}
}
7. Logging d'Errors
7.1. Exception Handler amb Context
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final AuditService auditService;
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDenied(
AccessDeniedException ex, HttpServletRequest request) {
Long userId = getCurrentUserId();
String resource = request.getRequestURI();
auditService.logAccessDenied(userId, resource, ex.getMessage());
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(new ErrorResponse("ACCESS_DENIED", "No tens permís per aquesta acció"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
String correlationId = MDC.get("correlationId");
log.error("Unexpected error correlationId={}", correlationId, ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR",
"Error intern. Referència: " + correlationId));
}
}
7.2. No Exposar Stack Traces
// ❌ MAI en producció
return ResponseEntity
.status(500)
.body(new ErrorResponse("ERROR", ex.getMessage()));
// ✅ CORRECTE
return ResponseEntity
.status(500)
.body(new ErrorResponse("INTERNAL_ERROR",
"Error intern. Contacta suport amb referència: " + correlationId));
8. Nivells de Log per Entorn
| Package | Dev | Staging | Prod |
|---|---|---|---|
com.linedance | DEBUG | INFO | INFO |
org.springframework.security | DEBUG | INFO | WARN |
org.hibernate.SQL | DEBUG | OFF | OFF |
org.hibernate.type | TRACE | OFF | OFF |
AUDIT (logger especial) | INFO | INFO | INFO |
Configuració
# application.yml
logging:
level:
com.linedance: ${LOG_LEVEL_APP:INFO}
org.springframework.security: ${LOG_LEVEL_SECURITY:WARN}
AUDIT: INFO # Sempre actiu
9. Emmascarament de Dades
9.1. Utilitat d'Emmascarament
public class LogMasker {
public static String maskEmail(String email) {
if (email == null || !email.contains("@")) return "***";
String[] parts = email.split("@");
String local = parts[0];
if (local.length() <= 2) return "**@" + parts[1];
return local.charAt(0) + "***" + local.charAt(local.length() - 1) + "@" + parts[1];
}
public static String maskToken(String token) {
if (token == null || token.length() < 10) return "***";
return token.substring(0, 6) + "..." + token.substring(token.length() - 4);
}
public static String maskIp(String ip) {
// Mantenir subnet per debugging, ocultar host
if (ip == null) return "***";
int lastDot = ip.lastIndexOf('.');
if (lastDot > 0) {
return ip.substring(0, lastDot) + ".***";
}
return "***";
}
}
9.2. Ús
log.info("Login attempt for email={}", LogMasker.maskEmail(email));
// Output: Login attempt for email=j***n@example.com
log.warn("Token validation failed: {}", LogMasker.maskToken(token));
// Output: Token validation failed: eyJhbG...xyz1
10. Checklist de Logging
Per Operació Nova
- [ ] Log INFO per operacions importants
- [ ] Log WARN per situacions anòmales però recuperables
- [ ] Log ERROR només per errors inesperats
- [ ] Correlation ID inclòs (via MDC)
- [ ] Sense secrets ni PII
- [ ] Emmascarament aplicat si cal
Per Error Handler Nou
- [ ] Log de l'error amb context
- [ ] Correlation ID a la resposta d'error
- [ ] Missatge d'usuari genèric
- [ ] Stack trace NO exposat al client
11. Referències
Historial de Canvis
| Data | Versió | Canvis |
|---|---|---|
| 2024-12 | 1.0 | Document inicial |