Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions specs/product/GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,16 @@ from the account onto each order (JPY has 0 decimals, USD 2 — the snapshot
records that too). Totals are recalculated from lines on every mutation,
never incrementally patched.

**Audit log (#93)** — append-only record of every corrective, destructive,
or configuration change (the admin-gated action set): actor (user id +
email snapshot), UTC timestamp, action code, entity reference, the reason
where the command carried one, and a small details payload. Domain data,
not telemetry (tech spec): written **in the same transaction** as the
change — a rollback erases the event with the change, and there is no
update or delete surface anywhere. Admin-only viewer at /audit. The
entity-local snapshots (`AdjustedFromJson` etc.) remain — they are the
record's own history; the audit log is the cross-cutting trail.

**Hen-day % (#91)** — eggs collected ÷ hen-days × 100 (spec §19.3). A
hen-day is one bird alive for one day; the day's bird count comes from the
bird ledger (placements + movements). The production report shows it per
Expand Down
51 changes: 51 additions & 0 deletions src/Cluckwork.Api/Endpoints/Audit/AuditEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace Cluckwork.Api.Endpoints.Audit;

using Cluckwork.Application.Features.Audit;
using Cluckwork.Domain.Auditing;
using Cluckwork.Infrastructure.Persistence;

// #93 — read-only audit viewer. There is deliberately NO mutation surface:
// events append inside the transactions that create them and never change.
public static class AuditEndpoints
{
private const int DefaultPageSize = 100;
private const int MaxPageSize = 500;

public static RouteGroupBuilder MapAuditEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", ListAuditEvents)
.WithName("ListAuditEvents")
.WithSummary("List audit events newest first (optional action/entity/date filters, paged).");

return group;
}

private static async Task<IResult> ListAuditEvents(
IAuditEventRepository events,
TenantContext tenant,
CancellationToken ct,
string? action = null,
Guid? entityId = null,
DateOnly? from = null,
DateOnly? to = null,
int? limit = null,
int? offset = null)
{
if (!tenant.IsResolved) return Results.Unauthorized();

var take = Math.Clamp(limit ?? DefaultPageSize, 1, MaxPageSize);
var skip = Math.Max(offset ?? 0, 0);

var list = await events.ListAsync(action, entityId, from, to, take, skip, ct);
return Results.Ok(list.Select(ToResponse));
}

private static AuditEventResponse ToResponse(AuditEvent e) =>
new(e.Id, e.OccurredAtUtc, e.ActorEmail, e.Action, e.EntityType, e.EntityId,
e.Reason, e.DetailsJson);
}

public sealed record AuditEventResponse(
Guid Id, DateTimeOffset OccurredAtUtc, string ActorEmail,
string Action, string EntityType, Guid EntityId,
string? Reason, string? DetailsJson);
12 changes: 10 additions & 2 deletions src/Cluckwork.Api/Middleware/TenantResolutionMiddleware.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
namespace Cluckwork.Api.Middleware;

using Cluckwork.Infrastructure.Identity;
using Cluckwork.Infrastructure.Persistence;

// Reads account_id claim from the authenticated principal and populates TenantContext
// before any endpoint handler runs (tech spec §4.2 point 1).
// before any endpoint handler runs (tech spec §4.2 point 1). Also resolves the
// acting user (sub + email) for the audit trail (#93).
public sealed class TenantResolutionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, TenantContext tenant)
public async Task InvokeAsync(HttpContext context, TenantContext tenant, CurrentUserContext user)
{
if (context.User.Identity?.IsAuthenticated == true)
{
var claim = context.User.FindFirst("account_id")?.Value;
if (Guid.TryParse(claim, out var accountId))
tenant.Resolve(accountId);

// MapInboundClaims is off: the raw JWT claim names survive.
var sub = context.User.FindFirst("sub")?.Value;
var email = context.User.FindFirst("email")?.Value;
if (Guid.TryParse(sub, out var userId))
user.Resolve(userId, email ?? "");
}

await next(context);
Expand Down
13 changes: 13 additions & 0 deletions src/Cluckwork.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using Cluckwork.Api;
using Cluckwork.Api.Endpoints.Accounts;
using Cluckwork.Api.Endpoints.Audit;
using Cluckwork.Api.Endpoints.Auth;
using Cluckwork.Api.Endpoints.Customers;
using Cluckwork.Api.Endpoints.DailyEntries;
Expand Down Expand Up @@ -92,6 +93,12 @@

// --- Multi-tenancy (scoped per request) ---
builder.Services.AddScoped<TenantContext>();
// The acting user, resolved beside the tenant (#93 audit trail).
builder.Services.AddScoped<Cluckwork.Infrastructure.Identity.CurrentUserContext>();
builder.Services.AddScoped<Cluckwork.Application.Common.ICurrentUser>(sp =>
sp.GetRequiredService<Cluckwork.Infrastructure.Identity.CurrentUserContext>());
builder.Services.AddScoped<Cluckwork.Application.Common.IAuditWriter, AuditWriter>();
builder.Services.AddScoped<Cluckwork.Application.Features.Audit.IAuditEventRepository, AuditEventRepository>();

// --- EF Core ---
var dbProvider = builder.Configuration["Database:Provider"] ?? "Postgres";
Expand Down Expand Up @@ -396,6 +403,12 @@
.RequireAuthorization()
.MapReportEndpoints();

// Audit trail (#93): read-only, admin-only.
app.MapGroup("/api/v1/audit")
.WithTags("Audit")
.RequireAuthorization(AuthPolicies.AdminOnly)
.MapAuditEndpoints();

app.MapGroup("/api/v1/users")
.WithTags("Users")
.RequireAuthorization(AuthPolicies.AdminOnly)
Expand Down
12 changes: 12 additions & 0 deletions src/Cluckwork.Application/Common/IAuditWriter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Cluckwork.Application.Common;

// Appends an audit event to the CURRENT unit of work (#93): the row commits
// or rolls back with the change it records — callers never save through this
// port, they call it just before their own SaveChanges/transaction commit.
public interface IAuditWriter
{
Task WriteAsync(
string action, string entityType, Guid entityId,
string? reason = null, object? details = null,
CancellationToken ct = default);
}
10 changes: 10 additions & 0 deletions src/Cluckwork.Application/Common/ICurrentUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Cluckwork.Application.Common;

// The authenticated actor of the current request (#93) — resolved from the
// JWT beside TenantContext. Handlers use it only through IAuditWriter.
public interface ICurrentUser
{
bool IsResolved { get; }
Guid UserId { get; }
string Email { get; }
}
12 changes: 12 additions & 0 deletions src/Cluckwork.Application/Features/Audit/IAuditEventRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Cluckwork.Application.Features.Audit;

using Cluckwork.Domain.Auditing;

// Read side of the audit trail (#93). Append happens through IAuditWriter;
// there is intentionally no update/remove surface anywhere.
public interface IAuditEventRepository
{
Task<IReadOnlyList<AuditEvent>> ListAsync(
string? action, Guid? entityId, DateOnly? from, DateOnly? to,
int limit, int offset, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ public sealed class AdjustDailyEntryHandler(
IEggGradeRepository eggGrades,
IBirdMovementRepository birdMovements,
IFlockRepository flocks,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result<AdjustDailyEntryResponse>> HandleAsync(
AdjustDailyEntryCommand command, Guid accountId, CancellationToken ct)
Expand Down Expand Up @@ -168,6 +169,10 @@ await birdMovements.AddAsync(BirdMovement.Create(
dailyEntryId: entry.Id), transactionCt);
}

// Same transaction as the change (#93): rolls back with it.
await audit.WriteAsync("DailyEntry.Adjust", nameof(DailyEntry), entry.Id,
command.Reason, ct: transactionCt);

return true;
}, ct);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public sealed class VoidDailyEntryHandler(
IEggGradeRepository eggGrades,
IBirdMovementRepository birdMovements,
IFlockRepository flocks,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result<VoidDailyEntryResponse>> HandleAsync(
VoidDailyEntryCommand command, Guid accountId, CancellationToken ct)
Expand Down Expand Up @@ -96,6 +97,10 @@ await birdMovements.AddAsync(BirdMovement.Create(
dailyEntryId: entry.Id), transactionCt);
}

// Same transaction as the change (#93): rolls back with it.
await audit.WriteAsync("DailyEntry.Void", nameof(DailyEntry), entry.Id,
command.Reason, ct: transactionCt);

return true;
}, ct);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ namespace Cluckwork.Application.Features.EggGrades.SetEggGradeActive;
// keep resolving the grade's name.
public sealed class SetEggGradeActiveHandler(
IEggGradeRepository grades,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result> HandleAsync(Guid eggGradeId, bool active, CancellationToken ct)
{
Expand All @@ -20,6 +21,10 @@ public async Task<Result> HandleAsync(Guid eggGradeId, bool active, Cancellation
var result = active ? grade.Activate() : grade.Deactivate();
if (result.IsFailure) return result;

// Same SaveChanges as the change (#93).
await audit.WriteAsync(active ? "EggGrade.Activate" : "EggGrade.Deactivate",
"EggGrade", grade.Id, ct: ct);

await unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace Cluckwork.Application.Features.EggGrades.UpdateEggGrade;

public sealed class UpdateEggGradeHandler(
IEggGradeRepository grades,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result> HandleAsync(UpdateEggGradeCommand command, CancellationToken ct)
{
Expand All @@ -20,6 +21,10 @@ public async Task<Result> HandleAsync(UpdateEggGradeCommand command, Cancellatio
var result = grade.Update(command.Name, command.SortOrder, command.IsSaleable);
if (result.IsFailure) return result;

// Same SaveChanges as the change (#93).
await audit.WriteAsync("EggGrade.Update", "EggGrade", grade.Id,
reason: null, details: new { grade.Name, grade.SortOrder, grade.IsSaleable }, ct: ct);

await unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ public sealed class AdjustExpenseHandler(
IExpenseRepository expenses,
IExpenseCategoryRepository categories,
IFlockRepository flocks,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result> HandleAsync(AdjustExpenseCommand command, CancellationToken ct)
{
Expand Down Expand Up @@ -43,11 +44,17 @@ public async Task<Result> HandleAsync(AdjustExpenseCommand command, Cancellation
"Expense.UnknownFlock", "The flock does not exist on this farm."));
}

var previousAmount = expense.AmountMinorUnits;
var result = expense.Adjust(
command.ExpenseCategoryId, command.Date, command.Description,
command.AmountMinorUnits, command.FlockId, command.Note);
if (result.IsFailure) return result;

// Same SaveChanges as the change (#93): commits or fails with it.
await audit.WriteAsync("Expense.Adjust", nameof(Cluckwork.Domain.Expenses.Expense), expense.Id,
reason: null,
new { previousAmountMinorUnits = previousAmount, newAmountMinorUnits = expense.AmountMinorUnits }, ct);

await unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace Cluckwork.Application.Features.Expenses.UpdateExpenseCategory;

public sealed class UpdateExpenseCategoryHandler(
IExpenseCategoryRepository categories,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result> HandleAsync(UpdateExpenseCategoryCommand command, CancellationToken ct)
{
Expand All @@ -29,6 +30,11 @@ public async Task<Result> HandleAsync(UpdateExpenseCategoryCommand command, Canc
if (flip.IsFailure) return flip;
}

// Same SaveChanges as the change (#93).
await audit.WriteAsync("ExpenseCategory.Update",
nameof(Cluckwork.Domain.Expenses.ExpenseCategory), category.Id,
reason: null, new { category.Name, category.Active }, ct);

await unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ namespace Cluckwork.Application.Features.Flocks.ArchiveFlock;

public sealed class ArchiveFlockHandler(
IFlockRepository flocks,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result> HandleAsync(Guid flockId, CancellationToken ct)
{
Expand All @@ -21,6 +22,10 @@ public async Task<Result> HandleAsync(Guid flockId, CancellationToken ct)
return result;

flocks.Update(flock);
// Same SaveChanges as the change (#93).
await audit.WriteAsync("Flock.Archive", "Flock", flock.Id,
reason: null, details: null, ct: ct);

await unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ namespace Cluckwork.Application.Features.Flocks.DepleteFlock;

public sealed class DepleteFlockHandler(
IFlockRepository flocks,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result> HandleAsync(Guid flockId, CancellationToken ct)
{
Expand All @@ -21,6 +22,10 @@ public async Task<Result> HandleAsync(Guid flockId, CancellationToken ct)
return result;

flocks.Update(flock);
// Same SaveChanges as the change (#93).
await audit.WriteAsync("Flock.Deplete", "Flock", flock.Id,
reason: null, details: null, ct: ct);

await unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ namespace Cluckwork.Application.Features.Flocks.ReactivateFlock;

public sealed class ReactivateFlockHandler(
IFlockRepository flocks,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result> HandleAsync(Guid flockId, CancellationToken ct)
{
Expand All @@ -20,6 +21,10 @@ public async Task<Result> HandleAsync(Guid flockId, CancellationToken ct)
return result;

flocks.Update(flock);
// Same SaveChanges as the change (#93).
await audit.WriteAsync("Flock.Reactivate", "Flock", flock.Id,
reason: null, details: null, ct: ct);

await unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ namespace Cluckwork.Application.Features.Flocks.RecordBirdMovement;
public sealed class RecordBirdMovementHandler(
IFlockRepository flocks,
IBirdMovementRepository movements,
IUnitOfWork unitOfWork)
IUnitOfWork unitOfWork,
IAuditWriter audit)
{
public async Task<Result<Guid>> HandleAsync(
RecordBirdMovementCommand command, Guid accountId, CancellationToken ct)
Expand All @@ -30,6 +31,10 @@ public async Task<Result<Guid>> HandleAsync(
command.Date, type, command.Quantity, command.Note);

await movements.AddAsync(movement, ct);
// Same SaveChanges as the change (#93).
await audit.WriteAsync("Flock.BirdMovement", "Flock", flock.Id,
reason: command.Note, details: new { command.Type, command.Quantity }, ct: ct);

await unitOfWork.SaveChangesAsync(ct);
return Result.Success(movement.Id);
}
Expand Down
Loading
Loading