Agent skill
add-audit-logging-tassadar2499-novatune
Implement tamper-evident audit logging with hash chain verification for admin actions (project)
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/other/add-audit-logging-tassadar2499-novatune
SKILL.md
Add Audit Logging Skill
Implement tamper-evident audit logging with SHA-256 hash chain verification for NovaTune admin operations.
Overview
Audit logging provides:
- Accountability: Track who did what, when, and why
- Compliance: Meet regulatory requirements (NF-3.5)
- Forensics: Investigate security incidents
- Tamper evidence: Hash chain detects modifications
Steps
1. Create Audit Log Entry Model
Location: src/NovaTuneApp/NovaTuneApp.ApiService/Models/Admin/AuditLogEntry.cs
namespace NovaTuneApp.ApiService.Models.Admin;
/// <summary>
/// Audit log entry for admin actions (NF-3.5).
/// </summary>
public sealed class AuditLogEntry
{
/// <summary>
/// RavenDB document ID: "AuditLogs/{ulid}".
/// </summary>
public string Id { get; init; } = string.Empty;
/// <summary>
/// Unique audit entry ID (ULID).
/// </summary>
[Required]
[MaxLength(26)]
public string AuditId { get; init; } = string.Empty;
/// <summary>
/// Admin user who performed the action.
/// </summary>
[Required]
public string ActorUserId { get; init; } = string.Empty;
/// <summary>
/// Actor's email at time of action (denormalized).
/// </summary>
[Required]
public string ActorEmail { get; init; } = string.Empty;
/// <summary>
/// Action performed (from AuditActions constants).
/// </summary>
[Required]
[MaxLength(64)]
public string Action { get; init; } = string.Empty;
/// <summary>
/// Resource type affected.
/// </summary>
[Required]
[MaxLength(32)]
public string TargetType { get; init; } = string.Empty;
/// <summary>
/// Target resource ID.
/// </summary>
[Required]
[MaxLength(64)]
public string TargetId { get; init; } = string.Empty;
/// <summary>
/// Reason code for the action.
/// </summary>
[MaxLength(64)]
public string? ReasonCode { get; init; }
/// <summary>
/// Free-text reason/notes from admin.
/// </summary>
[MaxLength(1000)]
public string? ReasonText { get; init; }
/// <summary>
/// Previous state (JSON serialized).
/// </summary>
public string? PreviousState { get; init; }
/// <summary>
/// New state (JSON serialized).
/// </summary>
public string? NewState { get; init; }
/// <summary>
/// Server timestamp when action occurred.
/// </summary>
public DateTimeOffset Timestamp { get; init; }
/// <summary>
/// Request correlation ID for tracing.
/// </summary>
[MaxLength(128)]
public string? CorrelationId { get; init; }
/// <summary>
/// IP address of the admin.
/// </summary>
[MaxLength(45)]
public string? IpAddress { get; init; }
/// <summary>
/// User agent of the admin client.
/// </summary>
[MaxLength(500)]
public string? UserAgent { get; init; }
/// <summary>
/// Hash of previous audit entry (tamper-evidence chain).
/// </summary>
[MaxLength(64)]
public string? PreviousEntryHash { get; init; }
/// <summary>
/// SHA-256 hash of this entry's content.
/// </summary>
[MaxLength(64)]
public string? ContentHash { get; init; }
/// <summary>
/// Expiration for document retention (1 year per NF-3.5).
/// </summary>
[JsonPropertyName("@expires")]
public DateTimeOffset? Expires { get; init; }
}
2. Create Action and Target Constants
Location: src/NovaTuneApp/NovaTuneApp.ApiService/Models/Admin/AuditConstants.cs
namespace NovaTuneApp.ApiService.Models.Admin;
/// <summary>
/// Standard audit action types.
/// </summary>
public static class AuditActions
{
// User management
public const string UserStatusChanged = "user.status_changed";
public const string UserRoleChanged = "user.role_changed";
public const string UserDeleted = "user.deleted";
public const string UserSessionsRevoked = "user.sessions_revoked";
// Track moderation
public const string TrackDeleted = "track.deleted";
public const string TrackModerated = "track.moderated";
public const string TrackDisabled = "track.disabled";
public const string TrackRestored = "track.restored";
// Audit log access
public const string AuditLogViewed = "audit.viewed";
public const string AuditLogExported = "audit.exported";
public const string AuditIntegrityChecked = "audit.integrity_checked";
}
/// <summary>
/// Target resource types for audit entries.
/// </summary>
public static class AuditTargetTypes
{
public const string User = "User";
public const string Track = "Track";
public const string AuditLog = "AuditLog";
}
3. Create Audit Log Service Interface
Location: src/NovaTuneApp/NovaTuneApp.ApiService/Services/Admin/IAuditLogService.cs
namespace NovaTuneApp.ApiService.Services.Admin;
public interface IAuditLogService
{
/// <summary>
/// Records an audit log entry with hash chain.
/// </summary>
Task<AuditLogEntry> LogAsync(
AuditLogRequest request,
CancellationToken ct = default);
/// <summary>
/// Lists audit log entries with filtering.
/// </summary>
Task<PagedResult<AuditLogListItem>> ListAsync(
AuditLogQuery query,
CancellationToken ct = default);
/// <summary>
/// Gets a specific audit log entry.
/// </summary>
Task<AuditLogDetails> GetAsync(
string auditId,
CancellationToken ct = default);
/// <summary>
/// Verifies integrity of audit log chain.
/// </summary>
Task<AuditIntegrityResult> VerifyIntegrityAsync(
DateOnly startDate,
DateOnly endDate,
CancellationToken ct = default);
}
public record AuditLogRequest(
string ActorUserId,
string ActorEmail,
string Action,
string TargetType,
string TargetId,
string? ReasonCode = null,
string? ReasonText = null,
object? PreviousState = null,
object? NewState = null,
string? CorrelationId = null,
string? IpAddress = null,
string? UserAgent = null);
public record AuditLogQuery(
string? ActorUserId = null,
string? Action = null,
string? TargetType = null,
string? TargetId = null,
DateOnly? StartDate = null,
DateOnly? EndDate = null,
string? Cursor = null,
int Limit = 50);
public record AuditIntegrityResult(
bool IsValid,
int EntriesChecked,
int InvalidEntries,
IReadOnlyList<string> InvalidAuditIds);
4. Implement Audit Log Service with Hash Chain
Location: src/NovaTuneApp/NovaTuneApp.ApiService/Services/Admin/AuditLogService.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace NovaTuneApp.ApiService.Services.Admin;
public class AuditLogService : IAuditLogService
{
private readonly IAsyncDocumentSession _session;
private readonly IOptions<AdminOptions> _options;
private readonly ILogger<AuditLogService> _logger;
public AuditLogService(
IAsyncDocumentSession session,
IOptions<AdminOptions> options,
ILogger<AuditLogService> logger)
{
_session = session;
_options = options;
_logger = logger;
}
public async Task<AuditLogEntry> LogAsync(
AuditLogRequest request,
CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var auditId = Ulid.NewUlid().ToString();
// Get hash of previous entry for chain
var previousEntry = await _session
.Query<AuditLogEntry>()
.OrderByDescending(e => e.Timestamp)
.FirstOrDefaultAsync(ct);
var previousHash = previousEntry?.ContentHash;
var entry = new AuditLogEntry
{
Id = $"AuditLogs/{auditId}",
AuditId = auditId,
ActorUserId = request.ActorUserId,
ActorEmail = request.ActorEmail,
Action = request.Action,
TargetType = request.TargetType,
TargetId = request.TargetId,
ReasonCode = request.ReasonCode,
ReasonText = request.ReasonText,
PreviousState = request.PreviousState is not null
? JsonSerializer.Serialize(request.PreviousState)
: null,
NewState = request.NewState is not null
? JsonSerializer.Serialize(request.NewState)
: null,
Timestamp = now,
CorrelationId = request.CorrelationId,
IpAddress = request.IpAddress,
UserAgent = request.UserAgent,
PreviousEntryHash = previousHash,
Expires = now.AddDays(_options.Value.AuditRetentionDays)
};
// Compute content hash
entry = entry with { ContentHash = ComputeHash(entry) };
await _session.StoreAsync(entry, ct);
await _session.SaveChangesAsync(ct);
_logger.LogInformation(
"Audit log created: {Action} on {TargetType}/{TargetId} by {ActorUserId}",
entry.Action, entry.TargetType, entry.TargetId, entry.ActorUserId);
return entry;
}
public async Task<AuditIntegrityResult> VerifyIntegrityAsync(
DateOnly startDate,
DateOnly endDate,
CancellationToken ct = default)
{
var entries = await _session
.Query<AuditLogEntry>()
.Where(e => e.Timestamp >= startDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)
&& e.Timestamp <= endDate.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc))
.OrderBy(e => e.Timestamp)
.ToListAsync(ct);
var invalidIds = new List<string>();
string? expectedPreviousHash = null;
// For first entry in range, get the actual previous entry's hash
if (entries.Count > 0)
{
var firstEntry = entries[0];
expectedPreviousHash = firstEntry.PreviousEntryHash;
// Verify we can trust the starting point
if (firstEntry.PreviousEntryHash is not null)
{
var actualPrevious = await _session
.Query<AuditLogEntry>()
.Where(e => e.Timestamp < firstEntry.Timestamp)
.OrderByDescending(e => e.Timestamp)
.FirstOrDefaultAsync(ct);
if (actualPrevious?.ContentHash != firstEntry.PreviousEntryHash)
{
invalidIds.Add(firstEntry.AuditId);
}
}
expectedPreviousHash = firstEntry.ContentHash;
}
// Verify chain within range
for (int i = 1; i < entries.Count; i++)
{
var entry = entries[i];
// Verify previous hash chain
if (entry.PreviousEntryHash != expectedPreviousHash)
{
invalidIds.Add(entry.AuditId);
_logger.LogWarning(
"Audit chain broken at {AuditId}: expected previous hash {Expected}, got {Actual}",
entry.AuditId, expectedPreviousHash, entry.PreviousEntryHash);
}
// Verify content hash
var computedHash = ComputeHash(entry);
if (entry.ContentHash != computedHash)
{
if (!invalidIds.Contains(entry.AuditId))
{
invalidIds.Add(entry.AuditId);
_logger.LogWarning(
"Audit content hash mismatch at {AuditId}: expected {Expected}, got {Actual}",
entry.AuditId, computedHash, entry.ContentHash);
}
}
expectedPreviousHash = entry.ContentHash;
}
var result = new AuditIntegrityResult(
invalidIds.Count == 0,
entries.Count,
invalidIds.Count,
invalidIds);
if (!result.IsValid)
{
_logger.LogError(
"Audit log integrity check FAILED: {InvalidCount} of {TotalCount} entries invalid",
result.InvalidEntries, result.EntriesChecked);
}
return result;
}
private static string ComputeHash(AuditLogEntry entry)
{
// Include all immutable fields in hash
var content = string.Join("|",
entry.AuditId,
entry.ActorUserId,
entry.ActorEmail,
entry.Action,
entry.TargetType,
entry.TargetId,
entry.ReasonCode ?? "",
entry.Timestamp.ToString("O"),
entry.PreviousState ?? "",
entry.NewState ?? "",
entry.PreviousEntryHash ?? "");
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(content));
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
// ListAsync and GetAsync implementations...
}
5. Create RavenDB Index
Location: src/NovaTuneApp/NovaTuneApp.ApiService/Infrastructure/Indexes/AuditLogs_ByFilters.cs
using Raven.Client.Documents.Indexes;
using NovaTuneApp.ApiService.Models.Admin;
namespace NovaTuneApp.ApiService.Infrastructure.Indexes;
public class AuditLogs_ByFilters : AbstractIndexCreationTask<AuditLogEntry>
{
public AuditLogs_ByFilters()
{
Map = entries => from entry in entries
select new
{
entry.ActorUserId,
entry.Action,
entry.TargetType,
entry.TargetId,
entry.Timestamp,
entry.ReasonCode
};
}
}
6. Add Configuration
Location: src/NovaTuneApp/NovaTuneApp.ApiService/Configuration/AdminOptions.cs
namespace NovaTuneApp.ApiService.Configuration;
public class AdminOptions
{
public const string SectionName = "Admin";
/// <summary>
/// Audit log retention period in days.
/// Default: 365 (1 year per NF-3.5).
/// </summary>
public int AuditRetentionDays { get; set; } = 365;
/// <summary>
/// Enable audit log integrity verification.
/// </summary>
public bool EnableIntegrityVerification { get; set; } = true;
/// <summary>
/// Maximum audit log entries per page.
/// </summary>
public int MaxAuditPageSize { get; set; } = 100;
}
7. Create Extension Method for Easy Logging
Location: src/NovaTuneApp/NovaTuneApp.ApiService/Extensions/AuditLogExtensions.cs
namespace NovaTuneApp.ApiService.Extensions;
public static class AuditLogExtensions
{
/// <summary>
/// Creates an audit log request from HTTP context.
/// </summary>
public static AuditLogRequest CreateAuditRequest(
this HttpContext context,
string action,
string targetType,
string targetId,
string? reasonCode = null,
string? reasonText = null,
object? previousState = null,
object? newState = null)
{
var user = context.User;
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier) ?? "unknown";
var email = user.FindFirstValue(ClaimTypes.Email) ?? "unknown";
var correlationId = Activity.Current?.Id;
var ipAddress = context.Connection.RemoteIpAddress?.ToString();
var userAgent = context.Request.Headers.UserAgent.ToString();
return new AuditLogRequest(
ActorUserId: userId,
ActorEmail: email,
Action: action,
TargetType: targetType,
TargetId: targetId,
ReasonCode: reasonCode,
ReasonText: reasonText,
PreviousState: previousState,
NewState: newState,
CorrelationId: correlationId,
IpAddress: ipAddress,
UserAgent: userAgent);
}
}
8. Usage in Admin Services
public class AdminUserService : IAdminUserService
{
private readonly IAuditLogService _auditLog;
public async Task<AdminUserDetails> UpdateUserStatusAsync(
string userId,
UpdateUserStatusRequest request,
string adminUserId,
HttpContext httpContext,
CancellationToken ct = default)
{
var user = await _session.LoadAsync<ApplicationUser>($"Users/{userId}", ct);
if (user is null)
throw new UserNotFoundException(userId);
var previousStatus = user.Status;
user.Status = request.Status;
// Create audit log entry
await _auditLog.LogAsync(
httpContext.CreateAuditRequest(
action: AuditActions.UserStatusChanged,
targetType: AuditTargetTypes.User,
targetId: userId,
reasonCode: request.ReasonCode,
reasonText: request.ReasonText,
previousState: new { Status = previousStatus },
newState: new { Status = request.Status }),
ct);
await _session.SaveChangesAsync(ct);
return MapToDetails(user);
}
}
Hash Chain Verification
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Entry #1 │ │ Entry #2 │ │ Entry #3 │
│ │ │ │ │ │
│ ContentHash: A │────►│ PrevHash: A │────►│ PrevHash: B │
│ │ │ ContentHash: B │ │ ContentHash: C │
└────────────────┘ └────────────────┘ └────────────────┘
If any entry is modified, its ContentHash changes,
breaking the chain verification for all subsequent entries.
Security Considerations
- Immutability: Audit entries should never be modified after creation
- Hash algorithm: Use SHA-256 for industry-standard security
- Include all fields: Hash must include all meaningful content
- Timestamp precision: Use ISO 8601 with timezone for consistency
- Retention: 1 year retention per NF-3.5, then auto-expire
Testing
[Fact]
public async Task LogAsync_Should_CreateEntry_WithHashChain()
{
// Arrange
var request = new AuditLogRequest(
ActorUserId: "admin1",
ActorEmail: "[email protected]",
Action: AuditActions.UserStatusChanged,
TargetType: AuditTargetTypes.User,
TargetId: "user1");
// Act
var entry1 = await _service.LogAsync(request);
var entry2 = await _service.LogAsync(request with { TargetId = "user2" });
// Assert
entry1.ContentHash.ShouldNotBeNullOrEmpty();
entry2.PreviousEntryHash.ShouldBe(entry1.ContentHash);
}
[Fact]
public async Task VerifyIntegrityAsync_Should_DetectTampering()
{
// Arrange - create entries
await _service.LogAsync(CreateRequest("user1"));
await _service.LogAsync(CreateRequest("user2"));
// Tamper with middle entry
var entry = await _session.Query<AuditLogEntry>().Skip(1).FirstAsync();
entry = entry with { ReasonCode = "tampered" };
await _session.SaveChangesAsync();
// Act
var result = await _service.VerifyIntegrityAsync(
DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-1)),
DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1)));
// Assert
result.IsValid.ShouldBeFalse();
result.InvalidEntries.ShouldBe(1);
}
Best Practices
- Log before commit: Create audit entry in same transaction as business operation
- Denormalize actor info: Store email at time of action (user might change later)
- Capture IP and User-Agent: Essential for security forensics
- Use correlation IDs: Link audit entries to distributed traces
- Never log PII in reason text: Redact sensitive information
- Alert on integrity failures: Notify security team immediately
- Periodic verification: Run integrity checks on schedule
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?