Skip to main content

Overview

The Secure Link API maintains a comprehensive audit trail of every link access attempt, regardless of success or failure. This provides visibility into link usage patterns, security events, and potential abuse.

Why Audit Tracking?

Security Monitoring

Detect unauthorized access attempts and potential security threats

Usage Analytics

Analyze link performance, popular content, and access patterns

Compliance

Maintain records for regulatory requirements and data governance

Troubleshooting

Diagnose access issues and validate security configuration

What Gets Tracked

Every access attempt generates an audit record with the following information:

Audit Record Structure

LinkAccessAudit.java:21-48
@Entity
@Table(name = "link_access_audit")
public class LinkAccessAudit {
  
  @Id
  @GeneratedValue(strategy = GenerationType.UUID)
  private UUID id;
  
  @Column(name = "short_code", nullable = false, length = 20)
  private String shortCode;
  
  @Enumerated(EnumType.STRING)
  @Column(nullable = false, length = 30)
  private AccessResult result;
  
  @Column(name = "ip_address", length = 45)
  private String ipAddress;
  
  @Column(name = "user_agent", length = 500)
  private String userAgent;
  
  @Column(name = "accessed_at", nullable = false)
  private OffsetDateTime accessedAt;
}

Field Descriptions

shortCode
string
required
The unique identifier of the link being accessed
result
AccessResult
required
The outcome of the access attempt (see AccessResult enum below)
ipAddress
string
Client IP address (supports both IPv4 and IPv6, max 45 characters)
userAgent
string
Client user agent string (browser, app, bot identifier)
accessedAt
OffsetDateTime
required
Timestamp when the access attempt occurred (timezone-aware)

AccessResult Enum

The AccessResult enum captures the outcome of every access validation:
package br.com.walyson.secure_link.domain.enums;

public enum AccessResult {
  SUCCESS,
  NOT_FOUND,
  REVOKED,
  EXPIRED,
  VIEW_LIMIT_REACHED,
  PASSWORD_REQUIRED,
  INVALID_PASSWORD,
  UNEXPECTED_STATE
}

Result Values

Access granted - All validation checks passed.Details:
  • Link exists and is ACTIVE
  • Not expired by time or view limit
  • Password validated (if required)
  • View count incremented
  • Content returned to client
HTTP Status: 200 OK or 302 Found
Link does not exist - No link found with the provided shortCode.Details:
  • shortCode not in database
  • Could indicate deleted link, typo, or enumeration attempt
  • Most common for expired/cleaned-up links
HTTP Status: 404 Not FoundSecurity Note: May indicate reconnaissance or brute-force attempts if frequent.
Link manually disabled - Administrator revoked the link.Details:
  • Link exists but status is REVOKED
  • Permanent denial (cannot be un-revoked)
  • Typically used for compromised or abused links
HTTP Status: 410 Gone
Time-based expiration - Link exceeded its expiresAt timestamp.Details:
  • Current time > expiresAt
  • Link status automatically updated to EXPIRED
  • Expected outcome for time-limited sharing
HTTP Status: 410 Gone
ResolveLinkServiceImpl.java:74-77
if (link.isExpired()) {
  repository.save(link);
  handleDenied(link.getShortCode(), AccessResult.EXPIRED, "expired", context);
}
Usage limit exceeded - Link has been accessed maxViews times.Details:
  • viewCount >= maxViews
  • Link status automatically updated to EXPIRED
  • Used for one-time or limited-distribution content
HTTP Status: 410 Gone
ResolveLinkServiceImpl.java:78-82
if (link.hasReachedViewLimit()) {
  link.expire();
  repository.save(link);
  handleDenied(link.getShortCode(), AccessResult.VIEW_LIMIT_REACHED, "view_limit_reached", context);
}
Missing password - Link requires password but none provided.Details:
  • Link has passwordProtected = true
  • Client did not provide password parameter
  • Informational denial (not necessarily malicious)
HTTP Status: 401 Unauthorized
ResolveLinkServiceImpl.java:88-90
if (password == null || password.isBlank()) {
  handleDenied(link.getShortCode(), AccessResult.PASSWORD_REQUIRED, 
    "password_required", HttpStatus.UNAUTHORIZED, "Password required", context);
}
Password mismatch - Provided password does not match stored hash.Details:
  • Password provided but BCrypt validation failed
  • Could indicate legitimate user error or attack attempt
  • Multiple failures may indicate brute-force attack
HTTP Status: 401 UnauthorizedSecurity Note: Monitor for repeated INVALID_PASSWORD from same IP.
ResolveLinkServiceImpl.java:91-93
if (!passwordEncoder.matches(password, link.getPasswordHash())) {
  handleDenied(link.getShortCode(), AccessResult.INVALID_PASSWORD, 
    "invalid_password", HttpStatus.UNAUTHORIZED, "Invalid password", context);
}
Invalid link state - Link exists but is not ACTIVE (edge case).Details:
  • Link passed existence check but isActive() returned false
  • Should be rare; indicates potential data consistency issue
  • May occur during concurrent status transitions
HTTP Status: 410 Gone
ResolveLinkServiceImpl.java:83-85
if (!link.isActive()) {
  handleDenied(link.getShortCode(), AccessResult.UNEXPECTED_STATE, "inactive", context);
}

Audit Recording Process

Audit records are created automatically for every access attempt, using a separate transaction to ensure records are preserved even if the main request fails.

Implementation

LinkAccesAuditServiceImpl.java:22-34
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void audit(String shortCode, AccessResult result, String ipAddress, String userAgent) {
  
  LinkAccessAudit audit = LinkAccessAudit.builder()
    .shortCode(shortCode)
    .result(result)
    .ipAddress(ipAddress)
    .userAgent(userAgent)
    .accessedAt(OffsetDateTime.now())
    .build();
  
  repository.save(audit);
}
The REQUIRES_NEW transaction propagation ensures audit records are committed even if the parent transaction rolls back. This guarantees no access attempt goes unrecorded.

Audit Points in Resolution Flow

Audit records are created at multiple points during link resolution:
ResolveLinkServiceImpl.java:57-105
// Point 1: Link not found
SecureLink link = repository.findByShortCode(shortCode)
  .orElseThrow(() -> {
    auditService.audit(shortCode, AccessResult.NOT_FOUND, context.ipAddress(), context.userAgent());
    throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Link not found");
  });

// Point 2: Validation failures
if (link.isRevoked()) {
  handleDenied(link.getShortCode(), AccessResult.REVOKED, "revoked", context);
  // handleDenied() calls auditService.audit()
}

// Point 3: Success
auditService.audit(shortCode, AccessResult.SUCCESS, context.ipAddress(), context.userAgent());

Access Context

Client information is captured from the HTTP request:
AccessContextDto.java
public record AccessContextDto(
  String ipAddress,
  String userAgent
) {}
This context is extracted from:
  • IP Address: X-Forwarded-For header or direct connection IP
  • User Agent: User-Agent HTTP header

Analytics and Reporting

The audit data powers various analytics endpoints:

Access Summary

GET /api/stats/access-summary?start=2024-01-01T00:00:00Z&end=2024-12-31T23:59:59Z
Returns aggregate statistics:
  • Total access attempts
  • Successful accesses
  • Failed accesses by reason
GET /api/stats/daily-access?start=2024-01-01&end=2024-12-31
GET /api/stats/hourly-access?start=2024-03-01T00:00:00Z&end=2024-03-01T23:59:59Z
Time-series data for traffic analysis.

Access by Result

GET /api/stats/access-by-result?start=2024-01-01T00:00:00Z&end=2024-12-31T23:59:59Z
Breakdown of access attempts by AccessResult value.

Security Exceptions

GET /api/stats/security-exceptions?limit=100
Recent failed access attempts for security monitoring.

Security Monitoring Use Cases

Detecting Brute Force Attacks

Monitor for multiple INVALID_PASSWORD results from the same IP:
SELECT ip_address, COUNT(*) as attempts
FROM link_access_audit
WHERE result = 'INVALID_PASSWORD'
  AND accessed_at > NOW() - INTERVAL '1 hour'
GROUP BY ip_address
HAVING COUNT(*) > 10
ORDER BY attempts DESC;
Identify potential shortCode enumeration attempts:
SELECT ip_address, COUNT(DISTINCT short_code) as unique_codes, COUNT(*) as attempts
FROM link_access_audit
WHERE result = 'NOT_FOUND'
  AND accessed_at > NOW() - INTERVAL '1 hour'
GROUP BY ip_address
HAVING COUNT(*) > 50
ORDER BY attempts DESC;
SELECT short_code, COUNT(*) as successful_accesses
FROM link_access_audit
WHERE result = 'SUCCESS'
  AND accessed_at > NOW() - INTERVAL '7 days'
GROUP BY short_code
ORDER BY successful_accesses DESC
LIMIT 10;

Best Practices

1

Regular Monitoring

Review security exceptions daily to detect abuse patterns early.
2

Retention Policy

Implement audit log retention policies based on compliance requirements (e.g., 90 days, 1 year).
3

Alerting

Set up automated alerts for:
  • Sudden spikes in NOT_FOUND results (enumeration)
  • High volumes of INVALID_PASSWORD from single IP (brute force)
  • Unusual access patterns outside business hours
4

Privacy Compliance

Ensure IP address storage complies with GDPR/privacy regulations. Consider IP anonymization for non-security use cases.
Audit logs may contain sensitive information (IP addresses, access patterns). Implement appropriate access controls and data retention policies.

Metrics Integration

The audit system integrates with Micrometer for real-time metrics:
ResolveLinkServiceImpl.java:53-55
Counter.builder("secure_link_resolve_attempts_total")
  .register(meterRegistry)
  .increment();
ResolveLinkServiceImpl.java:61-64
Counter.builder("secure_link_resolve_denied_total")
  .tag("reason", "not_found")
  .register(meterRegistry)
  .increment();
Metrics are exposed via /actuator/prometheus for monitoring systems.

Next Steps

Statistics API

Explore the complete statistics and analytics API

Access Control

Learn about validation flow and security constraints

Build docs developers (and LLMs) love