M1: audit log (Governance) + edit-distance metric

SharedKernel:
- IAuditLog/AuditEvent — append-only audit contract any module writes through.
- EditDistance (Levenshtein + normalized) — the north-star metric, available from day
  one; consumed at edit-and-approve in M5.

Governance module (references SharedKernel only):
- AuditEntry entity; internal GovernanceDbContext (schema "governance") +
  InitialGovernance migration; AuditLog implements IAuditLog.
- GET /api/governance/audit — owner-only (ViewAuditLog), returns recent entries.

Wiring (via the SharedKernel IAuditLog interface — no module references Governance):
- OrgBoard records team.created, task.created, task.moved, task.assigned.
- Identity records invitation.created, member.joined.

Verified: build green; ArchitectureTests 8/8 (Governance references only SharedKernel;
audit flows through the shared interface); IntegrationTests 20/20 — board flow now
asserts task.created/task.moved appear in the audit log, plus EditDistance unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-09 12:18:30 +03:30
parent e1911f58b1
commit fa9046a03e
16 changed files with 499 additions and 21 deletions
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.OrgBoard.Endpoints;
@@ -61,8 +62,8 @@ internal static class OrgBoardEndpoints
}
private static async Task<IResult> CreateTeam(
CreateTeamRequest request, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
CreateTeamRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(request.OrganizationId)))
{
@@ -82,6 +83,7 @@ internal static class OrgBoardEndpoints
var team = new Team(request.OrganizationId, request.Name.Trim(), clock.GetUtcNow());
db.Teams.Add(team);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("team.created", "Team", team.Id, user.MemberId, team.Name), ct);
return Results.Ok(new TeamResponse(team.Id, team.OrganizationId, team.Name));
}
@@ -104,7 +106,7 @@ internal static class OrgBoardEndpoints
private static async Task<IResult> CreateTask(
CreateTaskRequest request, ICurrentUser user, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
if (team is null)
@@ -125,6 +127,7 @@ internal static class OrgBoardEndpoints
var item = new WorkItem(team.Id, request.Title.Trim(), request.Description, request.Type, user.MemberId, clock.GetUtcNow());
db.WorkItems.Add(item);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.created", "WorkItem", item.Id, user.MemberId, item.Title), ct);
return Results.Ok(ToResponse(item));
}
@@ -153,8 +156,8 @@ internal static class OrgBoardEndpoints
}
private static async Task<IResult> MoveTask(
Guid id, MoveTaskRequest request, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
Guid id, MoveTaskRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
if (error is not null)
@@ -169,12 +172,13 @@ internal static class OrgBoardEndpoints
item!.MoveTo(request.Status, clock.GetUtcNow());
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.moved", "WorkItem", item.Id, user.MemberId, request.Status.ToString()), ct);
return Results.Ok(ToResponse(item));
}
private static async Task<IResult> AssignTask(
Guid id, AssignTaskRequest request, IPermissionService permissions,
OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
Guid id, AssignTaskRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
{
var (item, team, error) = await LoadItemWithTeam(db, id, ct);
if (error is not null)
@@ -189,6 +193,7 @@ internal static class OrgBoardEndpoints
item!.AssignToMember(request.MemberId, clock.GetUtcNow());
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("task.assigned", "WorkItem", item.Id, user.MemberId, request.MemberId.ToString()), ct);
return Results.Ok(ToResponse(item));
}