Compare commits

...

2 Commits

Author SHA1 Message Date
Chris Chen 62592c29ae Add audit logs.
ci-cd-vm / ci-cd (push) Successful in 4m2s
2026-06-23 12:13:47 -07:00
Chris Chen 870eeec82a Add role control 2026-06-23 07:19:08 -07:00
135 changed files with 4425 additions and 456 deletions
@@ -0,0 +1,64 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Moq;
using ROLAC.API.Authorization;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Authorization;
public class PermissionAuthorizationHandlerTests
{
private static ClaimsPrincipal UserWithRoles(params string[] roles)
{
var claims = roles.Select(role => new Claim("role", role));
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
private static async Task<bool> EvaluateAsync(
ClaimsPrincipal user, PermissionRequirement requirement, IPermissionService permissions)
{
var handler = new PermissionAuthorizationHandler(permissions);
var context = new AuthorizationHandlerContext([requirement], user, resource: null);
await handler.HandleAsync(context);
return context.HasSucceeded;
}
[Fact]
public async Task SuperAdmin_AlwaysSucceeds_WithoutConsultingMatrix()
{
var permissions = new Mock<IPermissionService>(MockBehavior.Strict); // must NOT be called
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Delete);
var succeeded = await EvaluateAsync(UserWithRoles("super_admin"), requirement, permissions.Object);
Assert.True(succeeded);
permissions.Verify(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task RoleWithPermission_Succeeds()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), Modules.Members, PermissionActions.Write))
.ReturnsAsync(true);
var requirement = new PermissionRequirement(Modules.Members, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("secretary"), requirement, permissions.Object);
Assert.True(succeeded);
}
[Fact]
public async Task RoleWithoutPermission_Fails()
{
var permissions = new Mock<IPermissionService>();
permissions.Setup(p => p.HasPermissionAsync(It.IsAny<IEnumerable<string>>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(false);
var requirement = new PermissionRequirement(Modules.Givings, PermissionActions.Write);
var succeeded = await EvaluateAsync(UserWithRoles("member"), requirement, permissions.Object);
Assert.False(succeeded);
}
}
@@ -26,7 +26,7 @@ public class AuditInterceptorTests
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>(); var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AuditSaveChangesInterceptor(mock.Object); return new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object));
} }
[Fact] [Fact]
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Configuration;
using Moq; using Moq;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Auth;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Services; using ROLAC.API.Services;
using Xunit; using Xunit;
@@ -72,11 +73,21 @@ public class AuthServiceTests
return svc; return svc;
} }
/// <summary>IPermissionService mock: returns an empty effective-permission map.</summary>
private static Mock<IPermissionService> BuildPermissionService()
{
var svc = new Mock<IPermissionService>();
svc.Setup(p => p.GetEffectivePermissionsAsync(It.IsAny<IEnumerable<string>>()))
.ReturnsAsync(new Dictionary<string, ModuleActions>());
return svc;
}
private static AuthService BuildSut( private static AuthService BuildSut(
Mock<UserManager<AppUser>> umMock, Mock<UserManager<AppUser>> umMock,
Mock<ITokenService> tsMock, Mock<ITokenService> tsMock,
AppDbContext db) AppDbContext db)
=> new(umMock.Object, tsMock.Object, db, BuildConfig()); => new(umMock.Object, tsMock.Object, db, BuildPermissionService().Object,
ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance, BuildConfig());
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Login tests // Login tests
@@ -49,7 +49,7 @@ public class DisbursementServiceTests
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId) private static DisbursementService SvcAs(AppDbContext db, FakeStorage fs, string userId)
@@ -57,7 +57,7 @@ public class DisbursementServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return new DisbursementService(db, http.Object, fs, new FakePrint()); return new DisbursementService(db, http.Object, fs, new FakePrint(), ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin") private static (DisbursementService svc, AppDbContext db, FakeStorage fs) Build(string userId = "fin")
@@ -203,7 +203,7 @@ public class DisbursementServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return (new DisbursementService(db, http.Object, fs, print), db, fs, print); return (new DisbursementService(db, http.Object, fs, print, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance), db, fs, print);
} }
[Fact] [Fact]
@@ -20,7 +20,7 @@ public class ExpenseCategoryServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
[Fact] [Fact]
@@ -33,7 +33,7 @@ public class ExpenseServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1") private static (ExpenseService svc, AppDbContext db, FakeStorage fs) Build(string userId = "u1")
@@ -52,7 +52,7 @@ public class ExpenseServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs); return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
// Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier), // Builds a service whose principal carries ONLY the "sub" claim (no NameIdentifier),
@@ -62,7 +62,7 @@ public class ExpenseServiceTests
var http = new Mock<IHttpContextAccessor>(); var http = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim("sub", userId) })) };
http.Setup(x => x.HttpContext).Returns(ctx); http.Setup(x => x.HttpContext).Returns(ctx);
return new ExpenseService(db, http.Object, fs); return new ExpenseService(db, http.Object, fs, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
private static CreateExpenseRequest Reimb() => new() private static CreateExpenseRequest Reimb() => new()
@@ -23,7 +23,7 @@ public class GivingCategoryServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -24,7 +24,7 @@ public class GivingServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -29,7 +29,7 @@ public class MemberServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var accessor = BuildAccessor(userId); var accessor = BuildAccessor(userId);
var interceptor = new AuditSaveChangesInterceptor(accessor); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(accessor));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -20,7 +20,7 @@ public class MinistryServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
[Fact] [Fact]
@@ -21,7 +21,7 @@ public class MonthlyStatementServiceTests
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>() return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(new AuditSaveChangesInterceptor(mock.Object)).Options); .AddInterceptors(new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(mock.Object))).Options);
} }
private static MonthlyStatementService Build(AppDbContext db) private static MonthlyStatementService Build(AppDbContext db)
@@ -29,7 +29,7 @@ public class MonthlyStatementServiceTests
var mock = new Mock<IHttpContextAccessor>(); var mock = new Mock<IHttpContextAccessor>();
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) }; var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })) };
mock.Setup(x => x.HttpContext).Returns(ctx); mock.Setup(x => x.HttpContext).Returns(ctx);
return new MonthlyStatementService(db, mock.Object); return new MonthlyStatementService(db, mock.Object, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
} }
[Fact] [Fact]
@@ -36,7 +36,7 @@ public class OfferingSessionServiceTests
private static AppDbContext BuildDb(string userId = "test-user") private static AppDbContext BuildDb(string userId = "test-user")
{ {
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor(userId)); var interceptor = new AuditSaveChangesInterceptor(new ROLAC.API.Services.Logging.CurrentUserAccessor(BuildAccessor(userId)));
return new AppDbContext( return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
@@ -0,0 +1,185 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ROLAC.API.Authorization;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Services;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class PermissionServiceTests
{
// -----------------------------------------------------------------------
// Harness: a real PermissionService backed by an in-memory EF database.
// -----------------------------------------------------------------------
private sealed class Harness
{
public required ServiceProvider Provider { get; init; }
public required PermissionService Service { get; init; }
public async Task SeedRoleAsync(string roleName, params RolePermission[] permissions)
{
using var scope = Provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var role = new AppRole { Id = $"role-{roleName}", Name = roleName, NormalizedName = roleName.ToUpperInvariant() };
db.Roles.Add(role);
foreach (var permission in permissions)
{
permission.RoleId = role.Id;
db.RolePermissions.Add(permission);
}
await db.SaveChangesAsync();
}
}
private static Harness BuildHarness()
{
var dbName = Guid.NewGuid().ToString();
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(opt => opt.UseInMemoryDatabase(dbName));
var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
var cache = new MemoryCache(new MemoryCacheOptions());
return new Harness
{
Provider = provider,
Service = new PermissionService(scopeFactory, cache,
new ROLAC.API.Services.Logging.SystemLogQueue(),
new Microsoft.AspNetCore.Http.HttpContextAccessor()),
};
}
private static RolePermission Perm(string module, bool r = false, bool w = false, bool d = false, bool a = false)
=> new() { Module = module, CanRead = r, CanWrite = w, CanDelete = d, CanApprove = a };
// -----------------------------------------------------------------------
// HasPermissionAsync
// -----------------------------------------------------------------------
[Fact]
public async Task HasPermission_RoleGrantsAction_ReturnsTrue()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task HasPermission_RoleLacksAction_ReturnsFalse()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Delete));
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Members, PermissionActions.Read));
}
[Fact]
public async Task HasPermission_UnionAcrossRoles_ReturnsTrueIfAnyRoleGrants()
{
var h = BuildHarness();
await h.SeedRoleAsync("pastor", Perm(Modules.Members, r: true));
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true, w: true));
// User holds both roles — should get the union.
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Read));
Assert.True(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Givings, PermissionActions.Write));
Assert.False(await h.Service.HasPermissionAsync(["pastor", "finance"], Modules.Members, PermissionActions.Delete));
}
// -----------------------------------------------------------------------
// GetEffectivePermissionsAsync
// -----------------------------------------------------------------------
[Fact]
public async Task GetEffectivePermissions_SuperAdmin_ReturnsAllModulesFull()
{
var h = BuildHarness(); // no rows seeded at all
var effective = await h.Service.GetEffectivePermissionsAsync(["super_admin"]);
Assert.Equal(Modules.All.Count, effective.Count);
foreach (var module in Modules.All)
{
Assert.True(effective[module].Read);
Assert.True(effective[module].Write);
Assert.True(effective[module].Delete);
Assert.True(effective[module].Approve);
}
}
[Fact]
public async Task GetEffectivePermissions_MergesFlagsAcrossRoles()
{
var h = BuildHarness();
await h.SeedRoleAsync("a", Perm(Modules.Expenses, r: true));
await h.SeedRoleAsync("b", Perm(Modules.Expenses, w: true, a: true));
var effective = await h.Service.GetEffectivePermissionsAsync(["a", "b"]);
Assert.True(effective[Modules.Expenses].Read);
Assert.True(effective[Modules.Expenses].Write);
Assert.True(effective[Modules.Expenses].Approve);
Assert.False(effective[Modules.Expenses].Delete);
}
[Fact]
public async Task GetEffectivePermissions_OmitsModulesWithNoGrant()
{
var h = BuildHarness();
await h.SeedRoleAsync("member"); // role exists but no grants
var effective = await h.Service.GetEffectivePermissionsAsync(["member"]);
Assert.Empty(effective);
}
// -----------------------------------------------------------------------
// Caching / invalidation via UpsertRoleAsync
// -----------------------------------------------------------------------
[Fact]
public async Task UpsertRole_InvalidatesCache_SoNextCheckReflectsNewState()
{
var h = BuildHarness();
await h.SeedRoleAsync("finance", Perm(Modules.Givings, r: true)); // read only
// Prime the cache with the original snapshot.
Assert.False(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
// Grant write; UpsertRoleAsync must invalidate the cache.
await h.Service.UpsertRoleAsync("finance", [new ModulePermissionDto
{
Module = Modules.Givings, CanRead = true, CanWrite = true,
}]);
Assert.True(await h.Service.HasPermissionAsync(["finance"], Modules.Givings, PermissionActions.Write));
}
[Fact]
public async Task UpsertRole_SuperAdmin_Throws()
{
var h = BuildHarness();
await h.SeedRoleAsync("super_admin");
await Assert.ThrowsAsync<InvalidOperationException>(
() => h.Service.UpsertRoleAsync("super_admin", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
[Fact]
public async Task UpsertRole_UnknownRole_Throws()
{
var h = BuildHarness();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => h.Service.UpsertRoleAsync("ghost", [new ModulePermissionDto { Module = Modules.Members, CanRead = true }]));
}
}
@@ -76,7 +76,7 @@ public class UserManagementServiceTests
mgr.Setup(m => m.Users) mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable()); .Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var result = await svc.CreateAsync(new CreateUserRequest var result = await svc.CreateAsync(new CreateUserRequest
{ {
MemberId = member.Id, MemberId = member.Id,
@@ -97,7 +97,7 @@ public class UserManagementServiceTests
var mgr = BuildUserManager(); var mgr = BuildUserManager();
mgr.Setup(m => m.Users) mgr.Setup(m => m.Users)
.Returns(new List<AppUser>().AsQueryable()); .Returns(new List<AppUser>().AsQueryable());
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest svc.CreateAsync(new CreateUserRequest
@@ -131,7 +131,7 @@ public class UserManagementServiceTests
// The service checks _userManager.Users — we need to return the existing user // The service checks _userManager.Users — we need to return the existing user
mgr.Setup(m => m.Users) mgr.Setup(m => m.Users)
.Returns(new List<AppUser> { existingUser }.AsQueryable()); .Returns(new List<AppUser> { existingUser }.AsQueryable());
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
svc.CreateAsync(new CreateUserRequest svc.CreateAsync(new CreateUserRequest
@@ -147,7 +147,7 @@ public class UserManagementServiceTests
var user = new AppUser var user = new AppUser
{ Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true }; { Id = "u1", UserName = "a@b.com", Email = "a@b.com", IsActive = true };
var mgr = BuildUserManager(findResult: user); var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await svc.DeactivateAsync("u1"); await svc.DeactivateAsync("u1");
@@ -160,7 +160,7 @@ public class UserManagementServiceTests
{ {
using var db = BuildDb(); using var db = BuildDb();
var mgr = BuildUserManager(findResult: null); var mgr = BuildUserManager(findResult: null);
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing")); await Assert.ThrowsAsync<KeyNotFoundException>(() => svc.DeactivateAsync("missing"));
} }
@@ -173,7 +173,7 @@ public class UserManagementServiceTests
using var db = BuildDb(); using var db = BuildDb();
var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" }; var user = new AppUser { Id = "u1", UserName = "a@b.com", Email = "a@b.com" };
var mgr = BuildUserManager(findResult: user); var mgr = BuildUserManager(findResult: user);
var svc = new UserManagementService(mgr.Object, db); var svc = new UserManagementService(mgr.Object, db, ROLAC.API.Tests.TestSupport.NullAuditLogger.Instance);
var pwd = await svc.ResetPasswordAsync("u1"); var pwd = await svc.ResetPasswordAsync("u1");
@@ -0,0 +1,19 @@
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Tests.TestSupport;
/// <summary>No-op <see cref="IAuditLogger"/> for unit tests that don't assert on audit output.</summary>
public sealed class NullAuditLogger : IAuditLogger
{
public static readonly NullAuditLogger Instance = new();
public void Write(
string action, string category, LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null, string? entityId = null, string? summary = null,
object? before = null, object? after = null,
string? userId = null, string? userEmail = null, string? ipAddress = null)
{
// intentionally empty
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Gates an action/controller on a configurable permission. Usage:
/// <c>[HasPermission(Modules.Members, PermissionActions.Write)]</c>.
/// Encodes the policy name <c>PERM:&lt;module&gt;:&lt;action&gt;</c>, which
/// <see cref="PermissionPolicyProvider"/> turns into a <see cref="PermissionRequirement"/>.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class HasPermissionAttribute : AuthorizeAttribute
{
public const string PolicyPrefix = "PERM:";
public HasPermissionAttribute(string module, string action)
=> Policy = $"{PolicyPrefix}{module}:{action}";
/// <summary>Parses a policy name back into (module, action), or null if not a PERM policy.</summary>
public static (string Module, string Action)? Parse(string policyName)
{
if (!policyName.StartsWith(PolicyPrefix, StringComparison.Ordinal))
return null;
var body = policyName[PolicyPrefix.Length..];
var split = body.IndexOf(':');
if (split <= 0 || split == body.Length - 1)
return null;
return (body[..split], body[(split + 1)..]);
}
}
+66
View File
@@ -0,0 +1,66 @@
namespace ROLAC.API.Authorization;
/// <summary>
/// Canonical list of permission-controlled modules. The names are stored verbatim
/// in <see cref="Entities.RolePermission.Module"/> and used in <c>[HasPermission]</c>
/// attributes, so changing a string here is a breaking change requiring a data update.
/// </summary>
public static class Modules
{
public const string Members = "Members";
public const string Users = "Users";
public const string Givings = "Givings";
public const string GivingCategories = "GivingCategories";
public const string Expenses = "Expenses";
public const string ExpenseCategories = "ExpenseCategories";
public const string OfferingSessions = "OfferingSessions";
public const string Ministries = "Ministries";
public const string FinanceDashboard = "FinanceDashboard";
public const string MonthlyStatements = "MonthlyStatements";
public const string ChurchProfile = "ChurchProfile";
public const string Disbursements = "Disbursements";
public const string MealAttendance = "MealAttendance";
public const string Permissions = "Permissions";
public const string SystemLogs = "SystemLogs";
public const string AuditLogs = "AuditLogs";
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
public static readonly IReadOnlyList<string> All =
[
Members,
Users,
Givings,
GivingCategories,
Expenses,
ExpenseCategories,
OfferingSessions,
Ministries,
FinanceDashboard,
MonthlyStatements,
ChurchProfile,
Disbursements,
MealAttendance,
Permissions,
SystemLogs,
AuditLogs,
];
public static bool IsValid(string module) => All.Contains(module);
}
/// <summary>
/// The four actions a role can be granted on a module. The default HTTP-verb mapping
/// is GET→Read, POST/PUT/PATCH→Write, DELETE→Delete; "Approve" is applied explicitly
/// to state-transition endpoints (approve / finalize / issue / sign, etc.).
/// </summary>
public static class PermissionActions
{
public const string Read = "Read";
public const string Write = "Write";
public const string Delete = "Delete";
public const string Approve = "Approve";
public static readonly IReadOnlyList<string> All = [Read, Write, Delete, Approve];
public static bool IsValid(string action) => All.Contains(action);
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using ROLAC.API.Services;
namespace ROLAC.API.Authorization;
/// <summary>
/// Evaluates <see cref="PermissionRequirement"/> against the user's roles.
/// <c>super_admin</c> always passes (bypass); otherwise the requirement succeeds if
/// ANY of the user's roles grants the requested module/action (union across roles).
/// </summary>
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
{
public const string SuperAdminRole = "super_admin";
private readonly IPermissionService _permissions;
public PermissionAuthorizationHandler(IPermissionService permissions)
=> _permissions = permissions;
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
// Roles live in "role" claims (RoleClaimType = "role", MapInboundClaims = false).
var roles = context.User.FindAll("role").Select(claim => claim.Value).ToList();
if (roles.Contains(SuperAdminRole))
{
context.Succeed(requirement);
return;
}
if (await _permissions.HasPermissionAsync(roles, requirement.Module, requirement.Action))
context.Succeed(requirement);
}
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Authorization;
/// <summary>
/// Materializes <c>PERM:&lt;module&gt;:&lt;action&gt;</c> policies on demand so we never
/// have to register every module/action combination at startup. Any other policy name
/// (including the default and <c>Roles=</c> policies) is delegated to the framework's
/// default provider, so existing <c>[Authorize(Roles=...)]</c> usages keep working.
/// </summary>
public class PermissionPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;
public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
var parsed = HasPermissionAttribute.Parse(policyName);
if (parsed is null)
return _fallback.GetPolicyAsync(policyName);
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddRequirements(new PermissionRequirement(parsed.Value.Module, parsed.Value.Action))
.Build();
return Task.FromResult<AuthorizationPolicy?>(policy);
}
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
namespace ROLAC.API.Authorization;
/// <summary>
/// Authorization requirement carrying the module + action a request needs.
/// Materialized on demand by <see cref="PermissionPolicyProvider"/> from a policy
/// name of the form <c>PERM:&lt;module&gt;:&lt;action&gt;</c>.
/// </summary>
public class PermissionRequirement : IAuthorizationRequirement
{
public string Module { get; }
public string Action { get; }
public PermissionRequirement(string module, string action)
{
Module = module;
Action = action;
}
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/audit-logs")]
[Authorize]
public class AuditLogsController : ControllerBase
{
private readonly IAuditLogQueryService _svc;
public AuditLogsController(IAuditLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] AuditLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>Category / action / level option lists for the filter UI.</summary>
[HttpGet("catalog")]
[HasPermission(Modules.AuditLogs, PermissionActions.Read)]
public IActionResult GetCatalog() => Ok(_svc.GetCatalog());
}
+39 -7
View File
@@ -1,6 +1,9 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
@@ -13,11 +16,14 @@ public class AuthController : ControllerBase
private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds private const int CookieMaxAge = 30 * 24 * 60 * 60; // 30 days in seconds
private readonly IAuthService _authService; private readonly IAuthService _authService;
private readonly UserManager<AppUser> _userManager;
private readonly IWebHostEnvironment _env; private readonly IWebHostEnvironment _env;
public AuthController(IAuthService authService, IWebHostEnvironment env) public AuthController(
IAuthService authService, UserManager<AppUser> userManager, IWebHostEnvironment env)
{ {
_authService = authService; _authService = authService;
_userManager = userManager;
_env = env; _env = env;
} }
@@ -79,17 +85,43 @@ public class AuthController : ControllerBase
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// GET /api/auth/me (dev-only diagnostic — remove before production) // GET /api/auth/me
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
/// <summary> /// <summary>
/// Returns the claims ASP.NET Core parsed from the Bearer token. /// Returns the current user's identity, roles, and effective permissions.
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates /// The SPA calls this on startup and after an admin edits the permission matrix
/// fine; if you then get 403 on /api/users the role claim isn't matching. /// to refresh what the UI shows — without forcing a re-login.
/// </summary> /// </summary>
[HttpGet("me")] [HttpGet("me")]
[Authorize] // no role restriction — just needs a valid JWT [Authorize]
public IActionResult GetMe() [ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMe()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var user = await _userManager.FindByIdAsync(userId);
if (user is null || !user.IsActive)
return Unauthorized();
var roles = await _userManager.GetRolesAsync(user);
return Ok(await _authService.BuildUserInfoAsync(user, roles));
}
// -------------------------------------------------------------------------
// GET /api/auth/claims (dev-only diagnostic)
// -------------------------------------------------------------------------
/// <summary>
/// Returns the raw claims ASP.NET Core parsed from the Bearer token.
/// Use this to debug 401 vs 403: if you get 200 here, the JWT validates fine;
/// if you then get 403 on a protected endpoint the role/permission isn't matching.
/// </summary>
[HttpGet("claims")]
[Authorize]
public IActionResult GetClaims()
{ {
var claims = User.Claims var claims = User.Claims
.Select(c => new { c.Type, c.Value }) .Select(c => new { c.Type, c.Value })
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement; using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,16 +8,18 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/church-profile")] [Route("api/church-profile")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class ChurchProfileController : ControllerBase public class ChurchProfileController : ControllerBase
{ {
private readonly IChurchProfileService _svc; private readonly IChurchProfileService _svc;
public ChurchProfileController(IChurchProfileService svc) => _svc = svc; public ChurchProfileController(IChurchProfileService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.ChurchProfile, PermissionActions.Read)]
public async Task<IActionResult> Get() => Ok(await _svc.GetAsync()); public async Task<IActionResult> Get() => Ok(await _svc.GetAsync());
[HttpPut] [HttpPut]
[HasPermission(Modules.ChurchProfile, PermissionActions.Write)]
public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r) public async Task<IActionResult> Update([FromBody] UpdateChurchProfileRequest r)
{ {
await _svc.UpdateAsync(r); await _svc.UpdateAsync(r);
@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Disbursement; using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -8,17 +9,19 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/disbursements")] [Route("api/disbursements")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class DisbursementsController : ControllerBase public class DisbursementsController : ControllerBase
{ {
private readonly IDisbursementService _svc; private readonly IDisbursementService _svc;
public DisbursementsController(IDisbursementService svc) => _svc = svc; public DisbursementsController(IDisbursementService svc) => _svc = svc;
[HttpGet("approved-unpaid")] [HttpGet("approved-unpaid")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetApprovedUnpaid() public async Task<IActionResult> GetApprovedUnpaid()
=> Ok(await _svc.GetApprovedUnpaidGroupedAsync()); => Ok(await _svc.GetApprovedUnpaidGroupedAsync());
[HttpPost("issue")] [HttpPost("issue")]
[HasPermission(Modules.Disbursements, PermissionActions.Write)]
public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r) public async Task<IActionResult> Issue([FromBody] IssueChecksRequest r)
{ {
try { return Ok(await _svc.IssueChecksAsync(r)); } try { return Ok(await _svc.IssueChecksAsync(r)); }
@@ -27,12 +30,14 @@ public class DisbursementsController : ControllerBase
} }
[HttpGet("checks")] [HttpGet("checks")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetRegister( public async Task<IActionResult> GetRegister(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null,
[FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) [FromQuery] string? search = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to)); => Ok(await _svc.GetRegisterAsync(page, pageSize, status, search, from, to));
[HttpGet("checks/{id:int}")] [HttpGet("checks/{id:int}")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
@@ -40,6 +45,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpPost("checks/{id:int}/void")] [HttpPost("checks/{id:int}/void")]
[HasPermission(Modules.Disbursements, PermissionActions.Delete)]
public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r) public async Task<IActionResult> Void(int id, [FromBody] VoidCheckRequest r)
{ {
try { await _svc.VoidAsync(id, r.Reason); return NoContent(); } try { await _svc.VoidAsync(id, r.Reason); return NoContent(); }
@@ -48,6 +54,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpGet("checks/{id:int}/pdf")] [HttpGet("checks/{id:int}/pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetPdf(int id) public async Task<IActionResult> GetPdf(int id)
{ {
var result = await _svc.RenderPdfAsync(id); var result = await _svc.RenderPdfAsync(id);
@@ -56,6 +63,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpGet("checks/{id:int}/receipt-pdf")] [HttpGet("checks/{id:int}/receipt-pdf")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetReceiptPdf(int id) public async Task<IActionResult> GetReceiptPdf(int id)
{ {
var result = await _svc.RenderReceiptPdfAsync(id); var result = await _svc.RenderReceiptPdfAsync(id);
@@ -64,6 +72,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpPost("checks/{id:int}/acknowledge")] [HttpPost("checks/{id:int}/acknowledge")]
[HasPermission(Modules.Disbursements, PermissionActions.Approve)]
[RequestSizeLimit(5_242_880)] [RequestSizeLimit(5_242_880)]
public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName) public async Task<IActionResult> Acknowledge(int id, [FromForm] IFormFile signature, [FromForm] string signedName)
{ {
@@ -82,6 +91,7 @@ public class DisbursementsController : ControllerBase
} }
[HttpGet("checks/{id:int}/signature")] [HttpGet("checks/{id:int}/signature")]
[HasPermission(Modules.Disbursements, PermissionActions.Read)]
public async Task<IActionResult> GetSignature(int id) public async Task<IActionResult> GetSignature(int id)
{ {
try try
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -19,32 +20,32 @@ public class ExpenseCategoriesController : ControllerBase
=> Ok(await _svc.GetAllAsync(includeInactive)); => Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost("groups")] [HttpPost("groups")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r) public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)
=> Ok(new { id = await _svc.CreateGroupAsync(r) }); => Ok(new { id = await _svc.CreateGroupAsync(r) });
[HttpPut("groups/{id:int}")] [HttpPut("groups/{id:int}")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r) public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateExpenseGroupRequest r)
{ try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } { try { await _svc.UpdateGroupAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("groups/{id:int}")] [HttpDelete("groups/{id:int}")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateGroup(int id) public async Task<IActionResult> DeactivateGroup(int id)
{ try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } { try { await _svc.DeactivateGroupAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPost("subcategories")] [HttpPost("subcategories")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r) public async Task<IActionResult> CreateSub([FromBody] CreateExpenseSubCategoryRequest r)
{ try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } } { try { return Ok(new { id = await _svc.CreateSubCategoryAsync(r) }); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpPut("subcategories/{id:int}")] [HttpPut("subcategories/{id:int}")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r) public async Task<IActionResult> UpdateSub(int id, [FromBody] UpdateExpenseSubCategoryRequest r)
{ try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } { try { await _svc.UpdateSubCategoryAsync(id, r); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
[HttpDelete("subcategories/{id:int}")] [HttpDelete("subcategories/{id:int}")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Delete)]
public async Task<IActionResult> DeactivateSub(int id) public async Task<IActionResult> DeactivateSub(int id)
{ try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } } { try { await _svc.DeactivateSubCategoryAsync(id); return NoContent(); } catch (KeyNotFoundException) { return NotFound(); } }
} }
+31 -14
View File
@@ -1,21 +1,38 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
// Class is [Authorize] only — any authenticated member may submit/view their OWN
// reimbursements. Finance-level privileges (view-all, edit-any, approve) are resolved
// against the configurable permission matrix on the "Expenses" module.
[ApiController] [ApiController]
[Route("api/expenses")] [Route("api/expenses")]
[Authorize] [Authorize]
public class ExpensesController : ControllerBase public class ExpensesController : ControllerBase
{ {
private readonly IExpenseService _svc; private readonly IExpenseService _svc;
public ExpensesController(IExpenseService svc) => _svc = svc; private readonly IPermissionService _perms;
public ExpensesController(IExpenseService svc, IPermissionService perms)
{
_svc = svc;
_perms = perms;
}
private bool IsFinance() => User.IsInRole("finance") || User.IsInRole("super_admin"); private List<string> Roles() => User.FindAll("role").Select(claim => claim.Value).ToList();
private bool CanViewAll() => IsFinance() || User.IsInRole("pastor"); private bool IsSuperAdmin() => User.IsInRole(PermissionAuthorizationHandler.SuperAdminRole);
// Can manage any expense (edit/delete/upload on others' records). Maps to Expenses:Write.
private async Task<bool> CanManageAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Write);
// Can view all expenses (not just own). Maps to Expenses:Read (finance + pastor by default).
private async Task<bool> CanViewAllAsync() =>
IsSuperAdmin() || await _perms.HasPermissionAsync(Roles(), Modules.Expenses, PermissionActions.Read);
// User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime. // User id lives in the "sub" claim (NameClaimType="sub"); NameIdentifier is absent at runtime.
private string CurrentUserId() => private string CurrentUserId() =>
@@ -28,7 +45,7 @@ public class ExpensesController : ControllerBase
[FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null, [FromQuery] string? status = null, [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null,
[FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null) [FromQuery] int? subCategoryId = null, [FromQuery] string? statuses = null)
{ {
if (!CanViewAll()) return Forbid(); if (!await CanViewAllAsync()) return Forbid();
return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses)); return Ok(await _svc.GetPagedAsync(page, pageSize, search, ministryId, categoryGroupId, status, from, to, subCategoryId, statuses));
} }
@@ -43,21 +60,21 @@ public class ExpensesController : ControllerBase
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
if (dto is null) return NotFound(); if (dto is null) return NotFound();
if (!CanViewAll() && dto.SubmittedBy != CurrentUserId()) return Forbid(); if (!await CanViewAllAsync() && dto.SubmittedBy != CurrentUserId()) return Forbid();
return Ok(dto); return Ok(dto);
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r) public async Task<IActionResult> Create([FromBody] CreateExpenseRequest r)
{ {
try { return Ok(new { id = await _svc.CreateAsync(r, IsFinance()) }); } try { return Ok(new { id = await _svc.CreateAsync(r, await CanManageAsync()) }); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r) public async Task<IActionResult> Update(int id, [FromBody] UpdateExpenseRequest r)
{ {
try { await _svc.UpdateAsync(id, r, IsFinance()); return NoContent(); } try { await _svc.UpdateAsync(id, r, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
} }
@@ -65,7 +82,7 @@ public class ExpensesController : ControllerBase
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id) public async Task<IActionResult> Delete(int id)
{ {
try { await _svc.DeleteAsync(id, IsFinance()); return NoContent(); } try { await _svc.DeleteAsync(id, await CanManageAsync()); return NoContent(); }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); } catch (InvalidOperationException ex) { return Conflict(new { message = ex.Message }); }
} }
@@ -79,7 +96,7 @@ public class ExpensesController : ControllerBase
} }
[HttpPost("{id:int}/approve")] [HttpPost("{id:int}/approve")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Approve(int id) public async Task<IActionResult> Approve(int id)
{ {
try { await _svc.ApproveAsync(id); return NoContent(); } try { await _svc.ApproveAsync(id); return NoContent(); }
@@ -88,7 +105,7 @@ public class ExpensesController : ControllerBase
} }
[HttpPost("{id:int}/reject")] [HttpPost("{id:int}/reject")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r) public async Task<IActionResult> Reject(int id, [FromBody] RejectExpenseRequest r)
{ {
try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); } try { await _svc.RejectAsync(id, r.ReviewNotes); return NoContent(); }
@@ -97,7 +114,7 @@ public class ExpensesController : ControllerBase
} }
[HttpPost("{id:int}/pay")] [HttpPost("{id:int}/pay")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.Expenses, PermissionActions.Approve)]
public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r) public async Task<IActionResult> Pay(int id, [FromBody] PayExpenseRequest r)
{ {
try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); } try { await _svc.PayAsync(id, r.CheckNumber, r.PaidAt); return NoContent(); }
@@ -115,7 +132,7 @@ public class ExpensesController : ControllerBase
try try
{ {
await using var stream = file.OpenReadStream(); await using var stream = file.OpenReadStream();
await _svc.SaveReceiptAsync(id, stream, file.FileName, IsFinance()); await _svc.SaveReceiptAsync(id, stream, file.FileName, await CanManageAsync());
return NoContent(); return NoContent();
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
@@ -127,7 +144,7 @@ public class ExpensesController : ControllerBase
{ {
try try
{ {
var result = await _svc.OpenReceiptAsync(id, IsFinance()); var result = await _svc.OpenReceiptAsync(id, await CanManageAsync());
if (result is null) return NotFound(); if (result is null) return NotFound();
return File(result.Value.stream, result.Value.contentType); return File(result.Value.stream, result.Value.contentType);
} }
@@ -1,12 +1,13 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.Services; using ROLAC.API.Services;
namespace ROLAC.API.Controllers; namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/finance-dashboard")] [Route("api/finance-dashboard")]
[Authorize(Roles = "finance,super_admin")] [HasPermission(Modules.FinanceDashboard, PermissionActions.Read)]
public class FinanceDashboardController : ControllerBase public class FinanceDashboardController : ControllerBase
{ {
private readonly IFinanceDashboardService _svc; private readonly IFinanceDashboardService _svc;
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving; using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/giving-categories")] [Route("api/giving-categories")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class GivingCategoriesController : ControllerBase public class GivingCategoriesController : ControllerBase
{ {
private readonly IGivingCategoryService _svc; private readonly IGivingCategoryService _svc;
public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc; public GivingCategoriesController(IGivingCategoryService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.GivingCategories, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false) public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive)); => Ok(await _svc.GetAllAsync(includeInactive));
[HttpPost] [HttpPost]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request) public async Task<IActionResult> Create([FromBody] CreateGivingCategoryRequest request)
{ {
var id = await _svc.CreateAsync(request); var id = await _svc.CreateAsync(request);
@@ -25,6 +28,7 @@ public class GivingCategoriesController : ControllerBase
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request) public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingCategoryRequest request)
{ {
try { await _svc.UpdateAsync(id, request); return NoContent(); } try { await _svc.UpdateAsync(id, request); return NoContent(); }
@@ -32,6 +36,7 @@ public class GivingCategoriesController : ControllerBase
} }
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[HasPermission(Modules.GivingCategories, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(int id) public async Task<IActionResult> Deactivate(int id)
{ {
try { await _svc.DeactivateAsync(id); return NoContent(); } try { await _svc.DeactivateAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving; using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,13 +8,14 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/givings")] [Route("api/givings")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class GivingsController : ControllerBase public class GivingsController : ControllerBase
{ {
private readonly IGivingService _svc; private readonly IGivingService _svc;
public GivingsController(IGivingService svc) => _svc = svc; public GivingsController(IGivingService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetPaged( public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] string? search = null, [FromQuery] int? categoryId = null, [FromQuery] string? search = null, [FromQuery] int? categoryId = null,
@@ -21,6 +23,7 @@ public class GivingsController : ControllerBase
=> Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to)); => Ok(await _svc.GetPagedAsync(page, pageSize, search, categoryId, from, to));
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
@@ -28,6 +31,7 @@ public class GivingsController : ControllerBase
} }
[HttpPost] [HttpPost]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateGivingRequest request) public async Task<IActionResult> Create([FromBody] CreateGivingRequest request)
{ {
var id = await _svc.CreateAsync(request); var id = await _svc.CreateAsync(request);
@@ -35,6 +39,7 @@ public class GivingsController : ControllerBase
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request) public async Task<IActionResult> Update(int id, [FromBody] UpdateGivingRequest request)
{ {
try { await _svc.UpdateAsync(id, request); return NoContent(); } try { await _svc.UpdateAsync(id, request); return NoContent(); }
@@ -43,6 +48,7 @@ public class GivingsController : ControllerBase
} }
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[HasPermission(Modules.Givings, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id) public async Task<IActionResult> Delete(int id)
{ {
try { await _svc.DeleteAsync(id); return NoContent(); } try { await _svc.DeleteAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Members; using ROLAC.API.DTOs.Members;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -15,7 +16,7 @@ public class MembersController : ControllerBase
/// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary> /// <summary>GET /api/members?page=1&pageSize=20&search=Chen&status=Member&hasUser=false</summary>
[HttpGet] [HttpGet]
[Authorize(Roles = "super_admin,secretary,pastor")] [HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetPaged( public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
@@ -26,7 +27,7 @@ public class MembersController : ControllerBase
/// <summary>GET /api/members/{id}</summary> /// <summary>GET /api/members/{id}</summary>
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[Authorize(Roles = "super_admin,secretary,pastor")] [HasPermission(Modules.Members, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _members.GetByIdAsync(id); var dto = await _members.GetByIdAsync(id);
@@ -35,7 +36,7 @@ public class MembersController : ControllerBase
/// <summary>POST /api/members</summary> /// <summary>POST /api/members</summary>
[HttpPost] [HttpPost]
[Authorize(Roles = "super_admin,secretary")] [HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMemberRequest request) public async Task<IActionResult> Create([FromBody] CreateMemberRequest request)
{ {
var id = await _members.CreateAsync(request); var id = await _members.CreateAsync(request);
@@ -44,7 +45,7 @@ public class MembersController : ControllerBase
/// <summary>PUT /api/members/{id}</summary> /// <summary>PUT /api/members/{id}</summary>
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[Authorize(Roles = "super_admin,secretary")] [HasPermission(Modules.Members, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request) public async Task<IActionResult> Update(int id, [FromBody] UpdateMemberRequest request)
{ {
try { await _members.UpdateAsync(id, request); return NoContent(); } try { await _members.UpdateAsync(id, request); return NoContent(); }
@@ -53,7 +54,7 @@ public class MembersController : ControllerBase
/// <summary>DELETE /api/members/{id} — soft delete</summary> /// <summary>DELETE /api/members/{id} — soft delete</summary>
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[Authorize(Roles = "super_admin,secretary")] [HasPermission(Modules.Members, PermissionActions.Delete)]
public async Task<IActionResult> Delete(int id) public async Task<IActionResult> Delete(int id)
{ {
try { await _members.DeleteAsync(id); return NoContent(); } try { await _members.DeleteAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,17 +8,19 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/monthly-statements")] [Route("api/monthly-statements")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class MonthlyStatementsController : ControllerBase public class MonthlyStatementsController : ControllerBase
{ {
private readonly IMonthlyStatementService _svc; private readonly IMonthlyStatementService _svc;
public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc; public MonthlyStatementsController(IMonthlyStatementService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetAll([FromQuery] int? year = null) public async Task<IActionResult> GetAll([FromQuery] int? year = null)
=> Ok(await _svc.GetAllAsync(year)); => Ok(await _svc.GetAllAsync(year));
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
@@ -25,6 +28,7 @@ public class MonthlyStatementsController : ControllerBase
} }
[HttpPost] [HttpPost]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r) public async Task<IActionResult> Create([FromBody] CreateMonthlyStatementRequest r)
{ {
try { return Ok(new { id = await _svc.CreateAsync(r) }); } try { return Ok(new { id = await _svc.CreateAsync(r) }); }
@@ -32,6 +36,7 @@ public class MonthlyStatementsController : ControllerBase
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Write)]
public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r) public async Task<IActionResult> Update(int id, [FromBody] UpdateMonthlyStatementRequest r)
{ {
try { await _svc.UpdateAsync(id, r); return NoContent(); } try { await _svc.UpdateAsync(id, r); return NoContent(); }
@@ -40,6 +45,7 @@ public class MonthlyStatementsController : ControllerBase
} }
[HttpPost("{id:int}/finalize")] [HttpPost("{id:int}/finalize")]
[HasPermission(Modules.MonthlyStatements, PermissionActions.Approve)]
public async Task<IActionResult> Finalize(int id) public async Task<IActionResult> Finalize(int id)
{ {
try { await _svc.FinalizeAsync(id); return NoContent(); } try { await _svc.FinalizeAsync(id); return NoContent(); }
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Giving; using ROLAC.API.DTOs.Giving;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,23 +8,26 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/offering-sessions")] [Route("api/offering-sessions")]
[Authorize(Roles = "finance,super_admin")] [Authorize]
public class OfferingSessionsController : ControllerBase public class OfferingSessionsController : ControllerBase
{ {
private readonly IOfferingSessionService _svc; private readonly IOfferingSessionService _svc;
public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc; public OfferingSessionsController(IOfferingSessionService svc) => _svc = svc;
[HttpGet] [HttpGet]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetPaged( public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] int page = 1, [FromQuery] int pageSize = 20,
[FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null) [FromQuery] DateOnly? from = null, [FromQuery] DateOnly? to = null)
=> Ok(await _svc.GetPagedAsync(page, pageSize, from, to)); => Ok(await _svc.GetPagedAsync(page, pageSize, from, to));
[HttpGet("check-date")] [HttpGet("check-date")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> CheckDate([FromQuery] DateOnly date) public async Task<IActionResult> CheckDate([FromQuery] DateOnly date)
=> Ok(new { exists = await _svc.DateExistsAsync(date) }); => Ok(new { exists = await _svc.DateExistsAsync(date) });
[HttpGet("{id:int}")] [HttpGet("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetById(int id) public async Task<IActionResult> GetById(int id)
{ {
var dto = await _svc.GetByIdAsync(id); var dto = await _svc.GetByIdAsync(id);
@@ -31,6 +35,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpPost] [HttpPost]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request) public async Task<IActionResult> Create([FromBody] CreateOfferingSessionRequest request)
{ {
try try
@@ -42,6 +47,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpPost("{id:int}/reopen")] [HttpPost("{id:int}/reopen")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Approve)]
public async Task<IActionResult> Reopen(int id) public async Task<IActionResult> Reopen(int id)
{ {
try { await _svc.ReopenAsync(id); return NoContent(); } try { await _svc.ReopenAsync(id); return NoContent(); }
@@ -50,6 +56,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpPut("{id:int}")] [HttpPut("{id:int}")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request) public async Task<IActionResult> Replace(int id, [FromBody] CreateOfferingSessionRequest request)
{ {
try { await _svc.ReplaceAsync(id, request); return NoContent(); } try { await _svc.ReplaceAsync(id, request); return NoContent(); }
@@ -60,6 +67,7 @@ public class OfferingSessionsController : ControllerBase
// ── Paper-proof PDF (merged client-side, one file per session) ─────────── // ── Paper-proof PDF (merged client-side, one file per session) ───────────
[HttpPost("{id:int}/proof")] [HttpPost("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Write)]
[RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt [RequestSizeLimit(52_428_800)] // 50 MB — a merged multi-image PDF is larger than one receipt
public async Task<IActionResult> UploadProof(int id, IFormFile file) public async Task<IActionResult> UploadProof(int id, IFormFile file)
{ {
@@ -75,6 +83,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpGet("{id:int}/proof")] [HttpGet("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Read)]
public async Task<IActionResult> GetProof(int id) public async Task<IActionResult> GetProof(int id)
{ {
try try
@@ -87,6 +96,7 @@ public class OfferingSessionsController : ControllerBase
} }
[HttpDelete("{id:int}/proof")] [HttpDelete("{id:int}/proof")]
[HasPermission(Modules.OfferingSessions, PermissionActions.Delete)]
public async Task<IActionResult> DeleteProof(int id) public async Task<IActionResult> DeleteProof(int id)
{ {
try { await _svc.DeleteProofAsync(id); return NoContent(); } try { await _svc.DeleteProofAsync(id); return NoContent(); }
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Services;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin surface for the configurable RBAC matrix. Restricted to super_admin —
/// the role that governs who governs everyone else.
/// </summary>
[ApiController]
[Route("api/permissions")]
[Authorize(Roles = "super_admin")]
public class PermissionsController : ControllerBase
{
private readonly IPermissionService _permissions;
public PermissionsController(IPermissionService permissions) => _permissions = permissions;
/// <summary>GET /api/permissions — the full role × module matrix.</summary>
[HttpGet]
public async Task<IActionResult> GetMatrix() => Ok(await _permissions.GetMatrixAsync());
/// <summary>GET /api/permissions/catalog — module + action names for the grid.</summary>
[HttpGet("catalog")]
public IActionResult GetCatalog() => Ok(new PermissionCatalogDto
{
Modules = Modules.All,
Actions = PermissionActions.All,
});
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
[HttpPut("{roleName}")]
public async Task<IActionResult> UpdateRole(string roleName, [FromBody] UpdateRolePermissionsRequest request)
{
try
{
await _permissions.UpsertRoleAsync(roleName, request.Modules);
return NoContent();
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { message = ex.Message }); }
}
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Controllers;
[ApiController]
[Route("api/system-logs")]
[Authorize]
public class SystemLogsController : ControllerBase
{
private readonly ISystemLogQueryService _svc;
public SystemLogsController(ISystemLogQueryService svc) => _svc = svc;
[HttpGet]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetPaged([FromQuery] SystemLogQuery query)
=> Ok(await _svc.GetPagedAsync(query));
[HttpGet("{id:long}")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public async Task<IActionResult> GetById(long id)
{
var dto = await _svc.GetByIdAsync(id);
return dto is null ? NotFound() : Ok(dto);
}
/// <summary>All six severities, so the UI can offer every filter option regardless of data.</summary>
[HttpGet("levels")]
[HasPermission(Modules.SystemLogs, PermissionActions.Read)]
public IActionResult GetLevels() => Ok(Enum.GetNames<LogLevelEnum>());
}
+8 -1
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Users; using ROLAC.API.DTOs.Users;
using ROLAC.API.Services; using ROLAC.API.Services;
@@ -7,7 +8,7 @@ namespace ROLAC.API.Controllers;
[ApiController] [ApiController]
[Route("api/users")] [Route("api/users")]
[Authorize(Roles = "super_admin")] [Authorize]
public class UsersController : ControllerBase public class UsersController : ControllerBase
{ {
private readonly IUserManagementService _users; private readonly IUserManagementService _users;
@@ -15,6 +16,7 @@ public class UsersController : ControllerBase
/// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary> /// <summary>GET /api/users?page=1&pageSize=20&search=Chris</summary>
[HttpGet] [HttpGet]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetPaged( public async Task<IActionResult> GetPaged(
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20, [FromQuery] int pageSize = 20,
@@ -23,6 +25,7 @@ public class UsersController : ControllerBase
/// <summary>GET /api/users/{id}</summary> /// <summary>GET /api/users/{id}</summary>
[HttpGet("{id}")] [HttpGet("{id}")]
[HasPermission(Modules.Users, PermissionActions.Read)]
public async Task<IActionResult> GetById(string id) public async Task<IActionResult> GetById(string id)
{ {
var dto = await _users.GetByIdAsync(id); var dto = await _users.GetByIdAsync(id);
@@ -34,6 +37,7 @@ public class UsersController : ControllerBase
/// TempPassword is returned ONCE — show it to the admin and never log it. /// TempPassword is returned ONCE — show it to the admin and never log it.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request) public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{ {
try try
@@ -49,6 +53,7 @@ public class UsersController : ControllerBase
/// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary> /// <summary>PUT /api/users/{id} — update email, roles, IsActive</summary>
[HttpPut("{id}")] [HttpPut("{id}")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request) public async Task<IActionResult> Update(string id, [FromBody] UpdateUserRequest request)
{ {
try { await _users.UpdateAsync(id, request); return NoContent(); } try { await _users.UpdateAsync(id, request); return NoContent(); }
@@ -58,6 +63,7 @@ public class UsersController : ControllerBase
/// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary> /// <summary>DELETE /api/users/{id} — deactivates account (IsActive=false), does not delete</summary>
[HttpDelete("{id}")] [HttpDelete("{id}")]
[HasPermission(Modules.Users, PermissionActions.Delete)]
public async Task<IActionResult> Deactivate(string id) public async Task<IActionResult> Deactivate(string id)
{ {
try { await _users.DeactivateAsync(id); return NoContent(); } try { await _users.DeactivateAsync(id); return NoContent(); }
@@ -66,6 +72,7 @@ public class UsersController : ControllerBase
/// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary> /// <summary>POST /api/users/{id}/reset-password — returns new temp password</summary>
[HttpPost("{id}/reset-password")] [HttpPost("{id}/reset-password")]
[HasPermission(Modules.Users, PermissionActions.Write)]
public async Task<IActionResult> ResetPassword(string id) public async Task<IActionResult> ResetPassword(string id)
{ {
try try
+8
View File
@@ -1,3 +1,5 @@
using ROLAC.API.DTOs.Permissions;
namespace ROLAC.API.DTOs.Auth; namespace ROLAC.API.DTOs.Auth;
public class LoginResponse public class LoginResponse
@@ -17,4 +19,10 @@ public class UserInfo
public string Email { get; set; } = null!; public string Email { get; set; } = null!;
public IList<string> Roles { get; set; } = []; public IList<string> Roles { get; set; } = [];
public string LanguagePreference { get; set; } = "en"; public string LanguagePreference { get; set; } = "en";
/// <summary>
/// Effective permissions (union across the user's roles), keyed by module name.
/// Lets the SPA hide nav/buttons. Authoritative enforcement is server-side.
/// </summary>
public Dictionary<string, ModuleActions> Permissions { get; set; } = [];
} }
@@ -0,0 +1,50 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the Audit Logs grid (no heavy Changes JSON).</summary>
public class AuditLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Action { get; set; } = null!;
public string Category { get; set; } = null!;
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? Summary { get; set; }
public string? UserId { get; set; }
public string? UserEmail { get; set; }
}
/// <summary>Full detail for the Audit Log dialog, including the before→after JSON.</summary>
public class AuditLogDetailDto : AuditLogListItemDto
{
public string? Changes { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Filters for the paged Audit Logs query.</summary>
public class AuditLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
public string? Category { get; set; }
public string? Action { get; set; }
public string? EntityName { get; set; }
public string? EntityId { get; set; }
public string? UserId { get; set; }
public LogLevelEnum? MinLevel { get; set; }
public string? Search { get; set; }
}
/// <summary>Option lists for the Audit Logs filter UI.</summary>
public class AuditCatalogDto
{
public IReadOnlyList<string> Categories { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
public IReadOnlyList<string> Levels { get; set; } = [];
}
@@ -0,0 +1,43 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.DTOs.Logging;
/// <summary>Row shape for the System Logs grid (no heavy exception text).</summary>
public class SystemLogListItemDto
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public string Level { get; set; } = null!;
public string Category { get; set; } = null!;
public string Message { get; set; } = null!;
public bool HasException { get; set; }
public int? StatusCode { get; set; }
public string? RequestPath { get; set; }
public string? HttpMethod { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Full detail for the System Log dialog, including the stack trace.</summary>
public class SystemLogDetailDto : SystemLogListItemDto
{
public int? EventId { get; set; }
public string? Exception { get; set; }
public string? IpAddress { get; set; }
}
/// <summary>Filters for the paged System Logs query.</summary>
public class SystemLogQuery
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public DateTimeOffset? From { get; set; }
public DateTimeOffset? To { get; set; }
/// <summary>Lower bound on severity (inclusive).</summary>
public LogLevelEnum? MinLevel { get; set; }
/// <summary>Exact severity match (takes precedence over MinLevel when set).</summary>
public LogLevelEnum? Level { get; set; }
public string? Search { get; set; }
public string? UserId { get; set; }
public string? CorrelationId { get; set; }
}
@@ -0,0 +1,53 @@
namespace ROLAC.API.DTOs.Permissions;
/// <summary>Effective action flags for one module (union across a user's roles).</summary>
public class ModuleActions
{
public bool Read { get; set; }
public bool Write { get; set; }
public bool Delete { get; set; }
public bool Approve { get; set; }
public bool Any => Read || Write || Delete || Approve;
}
/// <summary>One module's grant for a single role — used in the admin matrix and updates.</summary>
public class ModulePermissionDto
{
public string Module { get; set; } = null!;
public bool CanRead { get; set; }
public bool CanWrite { get; set; }
public bool CanDelete { get; set; }
public bool CanApprove { get; set; }
}
/// <summary>One role's full row in the admin matrix (every module, dense).</summary>
public class RolePermissionRow
{
public string RoleName { get; set; } = null!;
public string? Description { get; set; }
/// <summary>super_admin is shown read-only/full — it bypasses the matrix.</summary>
public bool IsSuperAdmin { get; set; }
public List<ModulePermissionDto> Modules { get; set; } = [];
}
/// <summary>GET /api/permissions — the whole matrix plus the catalog for grid headers.</summary>
public class PermissionMatrixDto
{
public IReadOnlyList<string> AllModules { get; set; } = [];
public IReadOnlyList<string> AllActions { get; set; } = [];
public List<RolePermissionRow> Roles { get; set; } = [];
}
/// <summary>GET /api/permissions/catalog — module + action names for building the UI.</summary>
public class PermissionCatalogDto
{
public IReadOnlyList<string> Modules { get; set; } = [];
public IReadOnlyList<string> Actions { get; set; } = [];
}
/// <summary>PUT /api/permissions/{roleName} — replaces a role's grants.</summary>
public class UpdateRolePermissionsRequest
{
public List<ModulePermissionDto> Modules { get; set; } = [];
}
+21
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities; using ROLAC.API.Entities;
namespace ROLAC.API.Data; namespace ROLAC.API.Data;
@@ -23,6 +24,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<Check> Checks => Set<Check>(); public DbSet<Check> Checks => Set<Check>();
public DbSet<CheckLine> CheckLines => Set<CheckLine>(); public DbSet<CheckLine> CheckLines => Set<CheckLine>();
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>(); public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
@@ -60,6 +62,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.Description).HasMaxLength(500); entity.Property(e => e.Description).HasMaxLength(500);
}); });
// ── RolePermission (configurable RBAC matrix) ───────────────────────
builder.Entity<RolePermission>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.RoleId).HasMaxLength(450).IsRequired();
entity.Property(e => e.Module).HasMaxLength(60).IsRequired();
// One row per (role, module).
entity.HasIndex(e => new { e.RoleId, e.Module }).IsUnique();
entity.HasOne(e => e.Role).WithMany()
.HasForeignKey(e => e.RoleId).OnDelete(DeleteBehavior.Cascade);
});
// ── FamilyUnit ────────────────────────────────────────────────────── // ── FamilyUnit ──────────────────────────────────────────────────────
builder.Entity<FamilyUnit>(entity => builder.Entity<FamilyUnit>(entity =>
{ {
@@ -311,5 +325,12 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.Property(e => e.UpdatedBy).HasMaxLength(450); entity.Property(e => e.UpdatedBy).HasMaxLength(450);
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique(); entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
}); });
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
// context, so business code can't write logs through the audited context.
// Runtime reads/writes go through the dedicated LogDbContext. Including
// them in the model lets the single startup migration create the tables.
LogModelConfiguration.Configure(builder);
} }
} }
+63
View File
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using ROLAC.API.Authorization;
using ROLAC.API.Entities; using ROLAC.API.Entities;
namespace ROLAC.API.Data; namespace ROLAC.API.Data;
@@ -62,6 +63,67 @@ public static class DbSeeder
("visitor", "Visitor — public pages only"), ("visitor", "Visitor — public pages only"),
]; ];
// Default permission matrix — mirrors the hard-coded [Authorize(Roles=...)] rules that
// existed before the configurable RBAC system, so day-one behavior is unchanged.
// super_admin is intentionally absent: it bypasses all checks (see PermissionAuthorizationHandler).
// R=Read, W=Write, D=Delete, A=Approve. Rows are inserted only if missing, so an admin's
// later edits via the Permissions UI are never clobbered on restart.
private static readonly (string Role, string Module, bool R, bool W, bool D, bool A)[] RolePermissionSeed =
[
// Secretary — manages member data.
("secretary", Modules.Members, true, true, true, false),
// Pastor — read-only overview of members and all expenses.
("pastor", Modules.Members, true, false, false, false),
("pastor", Modules.Expenses, true, false, false, false),
// Finance — full control over the finance modules.
("finance", Modules.Givings, true, true, true, false),
("finance", Modules.GivingCategories, true, true, true, false),
("finance", Modules.Expenses, true, true, true, true),
("finance", Modules.ExpenseCategories, true, true, true, false),
("finance", Modules.OfferingSessions, true, true, true, true),
("finance", Modules.FinanceDashboard, true, false, false, false),
("finance", Modules.MonthlyStatements, true, true, false, true),
("finance", Modules.ChurchProfile, true, true, false, false),
("finance", Modules.Disbursements, true, true, true, true),
// Logs — read-only. System logs are technical (pastor only); audit logs have
// governance value, so finance and board members can read them too.
("pastor", Modules.SystemLogs, true, false, false, false),
("pastor", Modules.AuditLogs, true, false, false, false),
("finance", Modules.AuditLogs, true, false, false, false),
("board_member", Modules.AuditLogs, true, false, false, false),
];
public static async Task SeedRolePermissionsAsync(AppDbContext db)
{
var rolesByName = await db.Roles
.Where(r => r.Name != null)
.ToDictionaryAsync(r => r.Name!, r => r.Id);
foreach (var (role, module, read, write, delete, approve) in RolePermissionSeed)
{
if (!rolesByName.TryGetValue(role, out var roleId))
continue;
var exists = await db.RolePermissions.AnyAsync(p => p.RoleId == roleId && p.Module == module);
if (exists)
continue; // never clobber an admin's edit
db.RolePermissions.Add(new RolePermission
{
RoleId = roleId,
Module = module,
CanRead = read,
CanWrite = write,
CanDelete = delete,
CanApprove = approve,
});
}
await db.SaveChangesAsync();
}
public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager) public static async Task SeedRolesAsync(RoleManager<AppRole> roleManager)
{ {
foreach (var (name, description) in Roles) foreach (var (name, description) in Roles)
@@ -159,6 +221,7 @@ public static class DbSeeder
await SeedRolesAsync(roleManager); await SeedRolesAsync(roleManager);
var db = services.GetRequiredService<AppDbContext>(); var db = services.GetRequiredService<AppDbContext>();
await SeedRolePermissionsAsync(db);
await SeedGivingCategoriesAsync(db); await SeedGivingCategoriesAsync(db);
await SeedMinistriesAsync(db); await SeedMinistriesAsync(db);
await SeedExpenseCategoriesAsync(db); await SeedExpenseCategoriesAsync(db);
@@ -0,0 +1,177 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors;
/// <summary>
/// Writes a before→after <see cref="AuditLog"/> row for every Create/Update/Delete of an
/// <see cref="IAuditable"/> entity. Two-phase: snapshot changed values BEFORE save (while
/// original values are still available), then — AFTER save succeeds — read DB-generated keys and
/// enqueue the rows. Enqueuing (rather than inserting here) avoids a second SaveChanges, can't
/// fail the user's transaction, and never recurses through AppDbContext.
/// </summary>
public sealed class AuditLogInterceptor : SaveChangesInterceptor
{
private readonly SystemLogQueue _queue;
private readonly CurrentUserAccessor _currentUser;
private readonly List<PendingAudit> _pending = [];
public AuditLogInterceptor(SystemLogQueue queue, CurrentUserAccessor currentUser)
{
_queue = queue;
_currentUser = currentUser;
}
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
Capture(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
Capture(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
public override int SavedChanges(SaveChangesCompletedEventData eventData, int result)
{
Flush();
return base.SavedChanges(eventData, result);
}
public override ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
{
Flush();
return base.SavedChangesAsync(eventData, result, cancellationToken);
}
public override void SaveChangesFailed(DbContextErrorEventData eventData) => _pending.Clear();
public override Task SaveChangesFailedAsync(
DbContextErrorEventData eventData, CancellationToken cancellationToken = default)
{
_pending.Clear();
return Task.CompletedTask;
}
// ── Phase 1: snapshot before save ─────────────────────────────────────────
private void Capture(DbContext? db)
{
if (db is null)
return;
foreach (var entry in db.ChangeTracker.Entries())
{
if (entry.Entity is not IAuditable)
continue;
switch (entry.State)
{
case EntityState.Added:
_pending.Add(new PendingAudit(entry, AuditActions.Create, null, BuildValues(entry, current: true)));
break;
case EntityState.Deleted:
_pending.Add(new PendingAudit(entry, AuditActions.Delete, BuildValues(entry, current: false), null));
break;
case EntityState.Modified:
var before = new Dictionary<string, object?>();
var after = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (!property.IsModified)
continue;
var name = property.Metadata.Name;
before[name] = Read(name, property.OriginalValue);
after[name] = Read(name, property.CurrentValue);
}
if (after.Count == 0)
break; // no real change (e.g. only audit timestamps touched on a no-op)
// A soft-delete (IsDeleted false→true) reads more naturally as a Delete.
var action = IsSoftDelete(after) ? AuditActions.Delete : AuditActions.Update;
_pending.Add(new PendingAudit(entry, action, before, after));
break;
}
}
}
// ── Phase 2: keys exist, enqueue ──────────────────────────────────────────
private void Flush()
{
if (_pending.Count == 0)
return;
var userId = _currentUser.UserId;
var userEmail = _currentUser.Email;
var ip = _currentUser.IpAddress;
var corr = _currentUser.CorrelationId;
foreach (var item in _pending)
{
_queue.TryEnqueue(new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelEnum.Information,
Action = item.Action,
Category = AuditCategories.DataChange,
EntityName = item.Entry.Metadata.ClrType.Name,
EntityId = ReadKey(item.Entry),
Changes = AuditChangeSerializer.BuildChanges(item.Before, item.After),
UserId = userId,
UserEmail = userEmail,
IpAddress = ip,
CorrelationId = corr,
});
}
_pending.Clear();
}
private static Dictionary<string, object?> BuildValues(EntityEntry entry, bool current)
{
var values = new Dictionary<string, object?>();
foreach (var property in entry.Properties)
{
if (property.Metadata.IsPrimaryKey())
continue;
var name = property.Metadata.Name;
values[name] = Read(name, current ? property.CurrentValue : property.OriginalValue);
}
return values;
}
private static object? Read(string propertyName, object? value) =>
AuditChangeSerializer.IsSensitive(propertyName) ? AuditChangeSerializer.MaskValue : value;
private static bool IsSoftDelete(Dictionary<string, object?> after) =>
after.TryGetValue("IsDeleted", out var value) && value is true;
private static string? ReadKey(EntityEntry entry)
{
var key = entry.Metadata.FindPrimaryKey();
if (key is null)
return null;
var parts = key.Properties
.Select(p => entry.Property(p.Name).CurrentValue?.ToString())
.Where(v => v is not null);
return string.Join(",", parts);
}
private sealed record PendingAudit(
EntityEntry Entry,
string Action,
Dictionary<string, object?>? Before,
Dictionary<string, object?>? After);
}
@@ -1,15 +1,15 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Diagnostics;
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Data.Interceptors; namespace ROLAC.API.Data.Interceptors;
public class AuditSaveChangesInterceptor : SaveChangesInterceptor public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{ {
private readonly IHttpContextAccessor _http; private readonly CurrentUserAccessor _currentUser;
public AuditSaveChangesInterceptor(IHttpContextAccessor http) => _http = http; public AuditSaveChangesInterceptor(CurrentUserAccessor currentUser) => _currentUser = currentUser;
public override InterceptionResult<int> SavingChanges( public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result) DbContextEventData eventData, InterceptionResult<int> result)
@@ -30,8 +30,7 @@ public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{ {
if (db is null) return; if (db is null) return;
var userId = _http.HttpContext?.User var userId = _currentUser.UserIdOrSystem;
.FindFirstValue(ClaimTypes.NameIdentifier) ?? "system";
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
foreach (var entry in db.ChangeTracker.Entries()) foreach (var entry in db.ChangeTracker.Entries())
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Data.Logging;
/// <summary>
/// A minimal, write-mostly context dedicated to the SystemLog / AuditLog tables. It is the
/// structural break that prevents log-storms: it is registered WITHOUT the audit interceptors
/// and with a silent logger factory (see Program.cs), so persisting a log row produces no log
/// events that the DB sink would pick up. It shares the same physical database/connection as
/// AppDbContext, but the tables themselves are created by AppDbContext's migration — they are
/// only mapped here so this context can read/write them.
/// </summary>
public class LogDbContext : DbContext
{
public LogDbContext(DbContextOptions<LogDbContext> options) : base(options) { }
public DbSet<SystemLog> SystemLogs => Set<SystemLog>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
LogModelConfiguration.Configure(builder);
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Data.Logging;
/// <summary>
/// Single source of truth for the SystemLog / AuditLog table schema. Applied by
/// <see cref="AppDbContext"/> (so the startup migration creates the tables) AND by
/// <see cref="LogDbContext"/> (so runtime reads/writes map to the same shape).
/// </summary>
public static class LogModelConfiguration
{
public static void Configure(ModelBuilder builder)
{
builder.Entity<SystemLog>(entity =>
{
entity.ToTable("SystemLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Level).HasConversion<byte>();
entity.Property(e => e.Category).HasMaxLength(256).IsRequired();
entity.Property(e => e.Message).IsRequired(); // text
entity.Property(e => e.RequestPath).HasMaxLength(2048);
entity.Property(e => e.HttpMethod).HasMaxLength(10);
entity.Property(e => e.UserId).HasMaxLength(450);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.CorrelationId).HasMaxLength(64);
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => e.Level);
entity.HasIndex(e => new { e.Timestamp, e.Level });
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
});
builder.Entity<AuditLog>(entity =>
{
entity.ToTable("AuditLogs");
entity.HasKey(e => e.Id);
entity.Property(e => e.Level).HasConversion<byte>();
entity.Property(e => e.Action).HasMaxLength(40).IsRequired();
entity.Property(e => e.Category).HasMaxLength(40).IsRequired();
entity.Property(e => e.EntityName).HasMaxLength(128);
entity.Property(e => e.EntityId).HasMaxLength(64);
entity.Property(e => e.Changes).HasColumnType("jsonb");
entity.Property(e => e.Summary).HasMaxLength(512);
entity.Property(e => e.UserId).HasMaxLength(450);
entity.Property(e => e.UserEmail).HasMaxLength(256);
entity.Property(e => e.IpAddress).HasMaxLength(45);
entity.Property(e => e.CorrelationId).HasMaxLength(64);
entity.HasIndex(e => e.Timestamp);
entity.HasIndex(e => new { e.Category, e.Timestamp });
entity.HasIndex(e => new { e.EntityName, e.EntityId });
entity.HasIndex(e => e.Action);
entity.HasIndex(e => e.UserId).HasFilter("\"UserId\" IS NOT NULL");
});
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ROLAC.API.Entities.Base;
/// <summary>
/// Opt-in marker: entities implementing this are diffed by <c>AuditLogInterceptor</c>, which
/// writes a before→after AuditLog row on every Create/Update/Delete. Applied only to business
/// entities the church cares about — not to internal/high-churn rows (RefreshToken, log tables).
/// </summary>
public interface IAuditable
{
}
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ROLAC.API.Entities;
/// expenses (its <see cref="Lines"/>). The payee name/address are snapshotted at /// expenses (its <see cref="Lines"/>). The payee name/address are snapshotted at
/// issue time so the printed check is reproducible even if member data later changes. /// issue time so the printed check is reproducible even if member data later changes.
/// </summary> /// </summary>
public class Check : SoftDeleteEntity public class Check : SoftDeleteEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string CheckNumber { get; set; } = null!; public string CheckNumber { get; set; } = null!;
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ROLAC.API.Entities;
/// Singleton (Id == 1) holding the issuing church's identity, bank details, and the /// Singleton (Id == 1) holding the issuing church's identity, bank details, and the
/// running check-number counter used when disbursing checks. Seeded on startup. /// running check-number counter used when disbursing checks. Seeded on startup.
/// </summary> /// </summary>
public class ChurchProfile : AuditableEntity public class ChurchProfile : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
+1 -1
View File
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class Expense : SoftDeleteEntity public class Expense : SoftDeleteEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public int MinistryId { get; set; } public int MinistryId { get; set; }
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class ExpenseCategoryGroup : AuditableEntity public class ExpenseCategoryGroup : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name_en { get; set; } = null!; public string Name_en { get; set; } = null!;
+1 -1
View File
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class ExpenseSubCategory : AuditableEntity public class ExpenseSubCategory : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public int GroupId { get; set; } public int GroupId { get; set; }
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class Giving : AuditableEntity public class Giving : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public int? MemberId { get; set; } public int? MemberId { get; set; }
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class GivingCategory : AuditableEntity public class GivingCategory : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name_en { get; set; } = null!; public string Name_en { get; set; } = null!;
@@ -0,0 +1,71 @@
namespace ROLAC.API.Entities.Logging;
/// <summary>
/// An append-only audit row recording a meaningful action: a data change (Create/Update/
/// Delete with before→after values), a security event (login, role/permission change), or a
/// key business action (check issued, expense approved, ...). Does NOT inherit AuditableEntity.
/// </summary>
public class AuditLog
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public LogLevelEnum Level { get; set; } = LogLevelEnum.Information;
/// <summary>One of <see cref="AuditActions"/>.</summary>
public string Action { get; set; } = null!;
/// <summary>One of <see cref="AuditCategories"/> — drives the UI grouping.</summary>
public string Category { get; set; } = null!;
public string? EntityName { get; set; }
/// <summary>String to cover int, Guid and string primary keys uniformly.</summary>
public string? EntityId { get; set; }
/// <summary>JSON <c>{ "before": {...}, "after": {...} }</c> (jsonb column); sensitive fields masked.</summary>
public string? Changes { get; set; }
/// <summary>Human-readable one-liner, e.g. "Check #1042 issued to Acme — $1,200.00".</summary>
public string? Summary { get; set; }
public string? UserId { get; set; }
/// <summary>Denormalized actor email — survives user deletion and avoids a join in the grid.</summary>
public string? UserEmail { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
/// <summary>Canonical audit action names (stored verbatim in <see cref="AuditLog.Action"/>).</summary>
public static class AuditActions
{
public const string Create = "Create";
public const string Update = "Update";
public const string Delete = "Delete";
public const string Login = "Login";
public const string Logout = "Logout";
public const string LoginFailed = "LoginFailed";
public const string RoleChanged = "RoleChanged";
public const string UserDeactivated = "UserDeactivated";
public const string PermissionChanged = "PermissionChanged";
public const string CheckIssued = "CheckIssued";
public const string CheckVoided = "CheckVoided";
public const string ExpenseApproved = "ExpenseApproved";
public const string StatementFinalized = "StatementFinalized";
public static readonly IReadOnlyList<string> All =
[
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
UserDeactivated, PermissionChanged, CheckIssued, CheckVoided,
ExpenseApproved, StatementFinalized,
];
}
/// <summary>Top-level audit grouping (stored verbatim in <see cref="AuditLog.Category"/>).</summary>
public static class AuditCategories
{
public const string DataChange = "DataChange";
public const string Security = "Security";
public const string Business = "Business";
public static readonly IReadOnlyList<string> All = [DataChange, Security, Business];
}
@@ -0,0 +1,38 @@
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Entities.Logging;
/// <summary>
/// Persisted severity for system and audit logs. Byte-backed so it stores compactly
/// as <c>smallint</c> and sorts/filters by ordinal. Deliberately omits the
/// <see cref="MsLogLevel.None"/> sentinel (value 6) — "None" means "log nothing" and
/// is meaningless once a row already exists.
/// </summary>
public enum LogLevelEnum : byte
{
Trace = 0,
Debug = 1,
Information = 2,
Warning = 3,
Error = 4,
Critical = 5,
}
public static class LogLevelMap
{
/// <summary>
/// Maps a framework <see cref="MsLogLevel"/> to our persisted enum.
/// <see cref="MsLogLevel.None"/> falls through to <see cref="LogLevelEnum.Critical"/>
/// (it never reaches the sink because the floor filter drops it first).
/// </summary>
public static LogLevelEnum FromMs(MsLogLevel level) => level switch
{
MsLogLevel.Trace => LogLevelEnum.Trace,
MsLogLevel.Debug => LogLevelEnum.Debug,
MsLogLevel.Information => LogLevelEnum.Information,
MsLogLevel.Warning => LogLevelEnum.Warning,
MsLogLevel.Error => LogLevelEnum.Error,
MsLogLevel.Critical => LogLevelEnum.Critical,
_ => LogLevelEnum.Critical,
};
}
@@ -0,0 +1,31 @@
namespace ROLAC.API.Entities.Logging;
/// <summary>
/// An append-only operational log row — one per persisted framework/app log event,
/// including every unhandled API exception captured by ExceptionHandlingMiddleware.
/// Intentionally does NOT inherit AuditableEntity: these rows are never updated and
/// must not be re-stamped or re-audited (that would recurse through the log pipeline).
/// </summary>
public class SystemLog
{
public long Id { get; set; }
public DateTimeOffset Timestamp { get; set; }
public LogLevelEnum Level { get; set; }
/// <summary>The ILogger category (source), e.g. "ROLAC.API.Controllers.GivingsController".</summary>
public string Category { get; set; } = null!;
public int? EventId { get; set; }
public string Message { get; set; } = null!;
/// <summary>Full <c>exception.ToString()</c> (type + message + stack), when present.</summary>
public string? Exception { get; set; }
public string? RequestPath { get; set; }
public string? HttpMethod { get; set; }
public int? StatusCode { get; set; }
/// <summary>The acting user id ("sub" claim), or null for background/system events.</summary>
public string? UserId { get; set; }
public string? IpAddress { get; set; }
public string? CorrelationId { get; set; }
}
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class Member : SoftDeleteEntity public class Member : SoftDeleteEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string FirstName_en { get; set; } = null!; public string FirstName_en { get; set; } = null!;
+3 -1
View File
@@ -1,6 +1,8 @@
using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class Ministry public class Ministry : IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name_en { get; set; } = null!; public string Name_en { get; set; } = null!;
+1 -1
View File
@@ -1,7 +1,7 @@
using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class MonthlyStatement : AuditableEntity public class MonthlyStatement : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public int Year { get; set; } public int Year { get; set; }
+1 -1
View File
@@ -2,7 +2,7 @@ using ROLAC.API.Entities.Base;
namespace ROLAC.API.Entities; namespace ROLAC.API.Entities;
public class OfferingSession : AuditableEntity public class OfferingSession : AuditableEntity, IAuditable
{ {
public int Id { get; set; } public int Id { get; set; }
public DateOnly SessionDate { get; set; } public DateOnly SessionDate { get; set; }
+25
View File
@@ -0,0 +1,25 @@
namespace ROLAC.API.Entities;
/// <summary>
/// One row per (Role × Module). Stores what the role may do on that module.
/// The effective permission for a user is the boolean OR of these flags across
/// all of the user's roles. <c>super_admin</c> is never stored here — it bypasses
/// permission checks entirely (see PermissionAuthorizationHandler).
/// </summary>
public class RolePermission
{
public int Id { get; set; }
/// <summary>FK to AspNetRoles.Id.</summary>
public string RoleId { get; set; } = null!;
/// <summary>Module constant name (see <see cref="Authorization.Modules"/>).</summary>
public string Module { get; set; } = null!;
public bool CanRead { get; set; }
public bool CanWrite { get; set; }
public bool CanDelete { get; set; }
public bool CanApprove { get; set; }
public AppRole Role { get; set; } = null!;
}
@@ -0,0 +1,78 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
namespace ROLAC.API.Middleware;
/// <summary>
/// Catches any unhandled exception from the downstream pipeline, logs it (which flows through
/// the DB sink into SystemLogs at Error level with full stack + StatusCode 500), and returns a
/// clean RFC7807 problem+json response. Stack traces are never leaked to the client outside
/// Development. Registered as the FIRST middleware so it also catches auth/authorization faults.
/// </summary>
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _env;
public ExceptionHandlingMiddleware(
RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger, IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// The client went away — not a server error; don't log as 500.
return;
}
catch (Exception ex)
{
await HandleAsync(context, ex);
}
}
private async Task HandleAsync(HttpContext context, Exception exception)
{
// Logged here → picked up by the DB sink (Error ≥ Warning floor) with full ex.ToString().
_logger.LogError(
exception,
"Unhandled exception for {Method} {Path} (traceId {TraceId})",
context.Request.Method, context.Request.Path, context.TraceIdentifier);
if (context.Response.HasStarted)
{
// Too late to write a clean body; the log row above is still captured.
return;
}
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred.",
Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1",
};
problem.Extensions["traceId"] = context.TraceIdentifier;
if (_env.IsDevelopment())
problem.Detail = exception.ToString();
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json";
await context.Response.WriteAsync(JsonSerializer.Serialize(problem, JsonSerializerOptions));
}
private static readonly JsonSerializerOptions JsonSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
}
@@ -885,6 +885,143 @@ namespace ROLAC.API.Migrations
b.ToTable("GivingCategories"); b.ToTable("GivingCategories");
}); });
modelBuilder.Entity("ROLAC.API.Entities.Logging.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("Changes")
.HasColumnType("jsonb");
b.Property<string>("CorrelationId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("EntityId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("EntityName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<byte>("Level")
.HasColumnType("smallint");
b.Property<string>("Summary")
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("Action");
b.HasIndex("Timestamp");
b.HasIndex("UserId")
.HasFilter("\"UserId\" IS NOT NULL");
b.HasIndex("Category", "Timestamp");
b.HasIndex("EntityName", "EntityId");
b.ToTable("AuditLogs", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.Logging.SystemLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("CorrelationId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int?>("EventId")
.HasColumnType("integer");
b.Property<string>("Exception")
.HasColumnType("text");
b.Property<string>("HttpMethod")
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("IpAddress")
.HasMaxLength(45)
.HasColumnType("character varying(45)");
b.Property<byte>("Level")
.HasColumnType("smallint");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RequestPath")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int?>("StatusCode")
.HasColumnType("integer");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<string>("UserId")
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("Level");
b.HasIndex("Timestamp");
b.HasIndex("UserId")
.HasFilter("\"UserId\" IS NOT NULL");
b.HasIndex("Timestamp", "Level");
b.ToTable("SystemLogs", (string)null);
});
modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b => modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -1310,6 +1447,44 @@ namespace ROLAC.API.Migrations
b.ToTable("RefreshTokens"); b.ToTable("RefreshTokens");
}); });
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("CanApprove")
.HasColumnType("boolean");
b.Property<bool>("CanDelete")
.HasColumnType("boolean");
b.Property<bool>("CanRead")
.HasColumnType("boolean");
b.Property<bool>("CanWrite")
.HasColumnType("boolean");
b.Property<string>("Module")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("RoleId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.HasKey("Id");
b.HasIndex("RoleId", "Module")
.IsUnique();
b.ToTable("RolePermissions");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{ {
b.HasOne("ROLAC.API.Entities.AppRole", null) b.HasOne("ROLAC.API.Entities.AppRole", null)
@@ -1481,6 +1656,17 @@ namespace ROLAC.API.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b =>
{
b.HasOne("ROLAC.API.Entities.AppRole", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
});
modelBuilder.Entity("ROLAC.API.Entities.AppUser", b => modelBuilder.Entity("ROLAC.API.Entities.AppUser", b =>
{ {
b.Navigation("RefreshTokens"); b.Navigation("RefreshTokens");
+43 -1
View File
@@ -6,11 +6,15 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Logging.Abstractions;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors; using ROLAC.API.Data.Interceptors;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Json; using ROLAC.API.Json;
using ROLAC.API.Middleware;
using ROLAC.API.Services; using ROLAC.API.Services;
using ROLAC.API.Services.Logging;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration; var config = builder.Configuration;
@@ -19,10 +23,31 @@ var config = builder.Configuration;
// Database // Database
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<CurrentUserAccessor>();
builder.Services.AddScoped<AuditSaveChangesInterceptor>(); builder.Services.AddScoped<AuditSaveChangesInterceptor>();
builder.Services.AddScoped<AuditLogInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opt) => builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
opt.UseNpgsql(config.GetConnectionString("DefaultConnection")) opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>())); .AddInterceptors(
sp.GetRequiredService<AuditSaveChangesInterceptor>(),
sp.GetRequiredService<AuditLogInterceptor>()));
// Dedicated context for log writes — NO interceptors and a silent logger factory, so persisting
// a log row produces no log events the DB sink would pick up (breaks recursion / log-storms).
builder.Services.AddDbContext<LogDbContext>(opt =>
opt.UseNpgsql(config.GetConnectionString("DefaultConnection"))
.UseLoggerFactory(NullLoggerFactory.Instance));
// ---------------------------------------------------------------------------
// System + audit logging (custom EF DB sink)
// ---------------------------------------------------------------------------
builder.Services.Configure<DatabaseLoggerOptions>(config.GetSection("Logging:Database"));
builder.Services.AddSingleton<SystemLogQueue>();
builder.Services.AddSingleton<ILoggerProvider, DbLoggerProvider>();
builder.Services.AddHostedService<LogWriterBackgroundService>();
builder.Services.AddScoped<IAuditLogger, AuditLogger>();
builder.Services.AddScoped<ISystemLogQueryService, SystemLogQueryService>();
builder.Services.AddScoped<IAuditLogQueryService, AuditLogQueryService>();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Identity (API-only — no cookie auth; JWT is the default scheme) // Identity (API-only — no cookie auth; JWT is the default scheme)
@@ -135,6 +160,19 @@ builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>(); ROLAC.API.Services.Disbursement.CheckPrintService>();
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>(); builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// ---------------------------------------------------------------------------
// Configurable role-based permissions (RBAC matrix)
// ---------------------------------------------------------------------------
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IPermissionService, PermissionService>();
builder.Services.AddAuthorization();
// Dynamic policy provider materializes "PERM:<module>:<action>" policies on demand;
// must be registered AFTER AddAuthorization so it overrides the default provider.
builder.Services.AddSingleton<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
ROLAC.API.Authorization.PermissionPolicyProvider>();
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
ROLAC.API.Authorization.PermissionAuthorizationHandler>();
// Real-time hub for the live Sunday attendance counter. // Real-time hub for the live Sunday attendance counter.
builder.Services.AddSignalR(); builder.Services.AddSignalR();
@@ -187,6 +225,10 @@ app.UseForwardedHeaders(new ForwardedHeadersOptions
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
}); });
// First in the pipeline: catch every unhandled exception, log it to SystemLogs, and return
// a clean problem+json. Placed after UseForwardedHeaders so the logged client IP is correct.
app.UseMiddleware<ExceptionHandlingMiddleware>();
// Apply migrations + seed on startup // Apply migrations + seed on startup
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
+50 -10
View File
@@ -4,6 +4,8 @@ using Microsoft.Extensions.Configuration;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -12,17 +14,23 @@ public class AuthService : IAuthService
private readonly UserManager<AppUser> _userManager; private readonly UserManager<AppUser> _userManager;
private readonly ITokenService _tokenService; private readonly ITokenService _tokenService;
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IPermissionService _permissions;
private readonly IAuditLogger _audit;
private readonly int _refreshTokenExpiryDays; private readonly int _refreshTokenExpiryDays;
public AuthService( public AuthService(
UserManager<AppUser> userManager, UserManager<AppUser> userManager,
ITokenService tokenService, ITokenService tokenService,
AppDbContext db, AppDbContext db,
IPermissionService permissions,
IAuditLogger audit,
IConfiguration config) IConfiguration config)
{ {
_userManager = userManager; _userManager = userManager;
_tokenService = tokenService; _tokenService = tokenService;
_db = db; _db = db;
_permissions = permissions;
_audit = audit;
_refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30"); _refreshTokenExpiryDays = int.Parse(config["Jwt:RefreshTokenExpiryDays"] ?? "30");
} }
@@ -35,13 +43,22 @@ public class AuthService : IAuthService
{ {
var user = await _userManager.FindByEmailAsync(request.Email); var user = await _userManager.FindByEmailAsync(request.Email);
if (user is null) if (user is null)
{
AuditLoginFailed(request.Email, "Unknown email", ipAddress);
throw new UnauthorizedAccessException("Invalid credentials."); throw new UnauthorizedAccessException("Invalid credentials.");
}
if (!await _userManager.CheckPasswordAsync(user, request.Password)) if (!await _userManager.CheckPasswordAsync(user, request.Password))
{
AuditLoginFailed(request.Email, "Wrong password", ipAddress, user.Id);
throw new UnauthorizedAccessException("Invalid credentials."); throw new UnauthorizedAccessException("Invalid credentials.");
}
if (!user.IsActive) if (!user.IsActive)
{
AuditLoginFailed(request.Email, "Account inactive", ipAddress, user.Id);
throw new UnauthorizedAccessException("Account is inactive."); throw new UnauthorizedAccessException("Account is inactive.");
}
var roles = await _userManager.GetRolesAsync(user); var roles = await _userManager.GetRolesAsync(user);
var accessToken = _tokenService.GenerateAccessToken(user, roles); var accessToken = _tokenService.GenerateAccessToken(user, roles);
@@ -62,9 +79,22 @@ public class AuthService : IAuthService
await _userManager.UpdateAsync(user); await _userManager.UpdateAsync(user);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return (BuildResponse(accessToken, user, roles), rawRefresh); _audit.Write(
AuditActions.Login, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Login succeeded: {user.Email}",
userId: user.Id, userEmail: user.Email, ipAddress: ipAddress);
return (await BuildResponseAsync(accessToken, user, roles), rawRefresh);
} }
private void AuditLoginFailed(string email, string reason, string? ipAddress, string? userId = null)
=> _audit.Write(
AuditActions.LoginFailed, AuditCategories.Security, LogLevelEnum.Warning,
entityName: nameof(AppUser), entityId: userId,
summary: $"Login failed ({reason}): {email}",
userId: userId, userEmail: email, ipAddress: ipAddress);
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Refresh // Refresh
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -104,7 +134,7 @@ public class AuthService : IAuthService
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
return (BuildResponse(newAccess, user, roles), newRaw); return (await BuildResponseAsync(newAccess, user, roles), newRaw);
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -121,6 +151,11 @@ public class AuthService : IAuthService
{ {
token.RevokedAt = DateTime.UtcNow; token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_audit.Write(
AuditActions.Logout, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: token.UserId,
summary: "Logout", userId: token.UserId);
} }
} }
@@ -128,18 +163,23 @@ public class AuthService : IAuthService
// Private helpers // Private helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private static LoginResponse BuildResponse( private async Task<LoginResponse> BuildResponseAsync(
string accessToken, AppUser user, IList<string> roles) string accessToken, AppUser user, IList<string> roles)
=> new() => new()
{ {
AccessToken = accessToken, AccessToken = accessToken,
ExpiresIn = 15 * 60, ExpiresIn = 15 * 60,
User = new UserInfo User = await BuildUserInfoAsync(user, roles),
{ };
Id = user.Id,
Email = user.Email!, /// <summary>Builds UserInfo including the effective permission map. Reused by /me.</summary>
Roles = roles, public async Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles)
LanguagePreference = user.LanguagePreference, => new()
}, {
Id = user.Id,
Email = user.Email!,
Roles = roles,
LanguagePreference = user.LanguagePreference,
Permissions = await _permissions.GetEffectivePermissionsAsync(roles),
}; };
} }
+15 -2
View File
@@ -4,7 +4,9 @@ using ROLAC.API.Data;
using ROLAC.API.DTOs.Disbursement; using ROLAC.API.DTOs.Disbursement;
using ROLAC.API.DTOs.Shared; using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Disbursement; using ROLAC.API.Services.Disbursement;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage; using ROLAC.API.Services.Storage;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -15,10 +17,11 @@ public class DisbursementService : IDisbursementService
private readonly IHttpContextAccessor _http; private readonly IHttpContextAccessor _http;
private readonly IFileStorage _storage; private readonly IFileStorage _storage;
private readonly ICheckPrintService _print; private readonly ICheckPrintService _print;
private readonly IAuditLogger _audit;
public DisbursementService(AppDbContext db, IHttpContextAccessor http, public DisbursementService(AppDbContext db, IHttpContextAccessor http,
IFileStorage storage, ICheckPrintService print) IFileStorage storage, ICheckPrintService print, IAuditLogger audit)
{ _db = db; _http = http; _storage = storage; _print = print; } { _db = db; _http = http; _storage = storage; _print = print; _audit = audit; }
// The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier // The JWT carries the user id in the "sub" claim (NameClaimType="sub"); NameIdentifier
// is absent at runtime. Check NameIdentifier first (tests), then "sub" (real tokens). // is absent at runtime. Check NameIdentifier first (tests), then "sub" (real tokens).
@@ -157,6 +160,11 @@ public class DisbursementService : IDisbursementService
result.Created.Add(new IssuedCheckDto result.Created.Add(new IssuedCheckDto
{ CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount }); { CheckId = check.Id, CheckNumber = checkNumber, PayeeName = p.PayeeName, Amount = amount });
_audit.Write(
AuditActions.CheckIssued, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Check), entityId: check.Id.ToString(),
summary: $"Check #{checkNumber} issued to {p.PayeeName} — {amount:C}");
} }
await tx.CommitAsync(); await tx.CommitAsync();
@@ -227,6 +235,11 @@ public class DisbursementService : IDisbursementService
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
_audit.Write(
AuditActions.CheckVoided, AuditCategories.Business, LogLevelEnum.Warning,
entityName: nameof(Check), entityId: c.Id.ToString(),
summary: $"Check #{c.CheckNumber} voided ({reason})");
} }
// ── Receipt e-signature ───────────────────────────────────────────────────── // ── Receipt e-signature ─────────────────────────────────────────────────────
+10 -2
View File
@@ -4,6 +4,8 @@ using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.DTOs.Shared; using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Storage; using ROLAC.API.Services.Storage;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -13,9 +15,10 @@ public class ExpenseService : IExpenseService
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http; private readonly IHttpContextAccessor _http;
private readonly IFileStorage _storage; private readonly IFileStorage _storage;
private readonly IAuditLogger _audit;
public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage) public ExpenseService(AppDbContext db, IHttpContextAccessor http, IFileStorage storage, IAuditLogger audit)
{ _db = db; _http = http; _storage = storage; } { _db = db; _http = http; _storage = storage; _audit = audit; }
// The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false), // The JWT carries the user id in the "sub" claim (NameClaimType="sub", MapInboundClaims=false),
// so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it), // so ClaimTypes.NameIdentifier is absent at runtime. Check NameIdentifier first (unit tests set it),
@@ -211,6 +214,11 @@ public class ExpenseService : IExpenseService
if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'."); if (e.Status != "PendingApproval") throw new InvalidOperationException($"Cannot approve from status '{e.Status}'.");
e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow; e.Status = "Approved"; e.ReviewedBy = CurrentUserId; e.ReviewedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_audit.Write(
AuditActions.ExpenseApproved, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(Expense), entityId: e.Id.ToString(),
summary: $"Expense #{e.Id} approved: {e.Description} — {e.Amount:C}");
} }
public async Task RejectAsync(int id, string? reviewNotes) public async Task RejectAsync(int id, string? reviewNotes)
+8
View File
@@ -1,4 +1,5 @@
using ROLAC.API.DTOs.Auth; using ROLAC.API.DTOs.Auth;
using ROLAC.API.Entities;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -28,4 +29,11 @@ public interface IAuthService
/// Silently succeeds if the token is not found. /// Silently succeeds if the token is not found.
/// </summary> /// </summary>
Task LogoutAsync(string rawRefreshToken); Task LogoutAsync(string rawRefreshToken);
/// <summary>
/// Builds the UserInfo payload (identity, roles, and effective permissions) for an
/// already-authenticated user. Used by GET /api/auth/me to refresh permissions
/// after an admin edits the matrix, without forcing a re-login.
/// </summary>
Task<UserInfo> BuildUserInfoAsync(AppUser user, IList<string> roles);
} }
@@ -0,0 +1,71 @@
using System.Text.Json;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Serializes audit before/after payloads to JSON and masks sensitive property names.
/// Shared by <see cref="AuditLogger"/> and the EF audit interceptor so masking is consistent.
/// </summary>
public static class AuditChangeSerializer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
};
/// <summary>Property names whose values are replaced with <see cref="MaskValue"/> wherever they appear.</summary>
private static readonly HashSet<string> SensitiveNames = new(StringComparer.OrdinalIgnoreCase)
{
"BankAccountNumber",
"BankRoutingNumber",
"PasswordHash",
"Password",
"SecurityStamp",
"ConcurrencyStamp",
};
public const string MaskValue = "***";
public static bool IsSensitive(string propertyName) => SensitiveNames.Contains(propertyName);
/// <summary>Builds the <c>{ before, after }</c> JSON; returns null when both sides are empty.</summary>
public static string? BuildChanges(object? before, object? after)
{
if (before is null && after is null)
return null;
var payload = new Dictionary<string, object?>();
if (before is not null) payload["before"] = MaskObject(before);
if (after is not null) payload["after"] = MaskObject(after);
return JsonSerializer.Serialize(payload, JsonOptions);
}
/// <summary>Serializes a value (e.g. a property dictionary built by the interceptor) to JSON.</summary>
public static string Serialize(object value) => JsonSerializer.Serialize(value, JsonOptions);
/// <summary>
/// Masks a free-form object by reflecting over its public properties. Used for the explicit
/// IAuditLogger.Write path (the interceptor masks per-property as it builds its dictionary).
/// </summary>
private static object MaskObject(object value)
{
if (value is IDictionary<string, object?> dict)
{
var masked = new Dictionary<string, object?>();
foreach (var (key, val) in dict)
masked[key] = IsSensitive(key) ? MaskValue : val;
return masked;
}
var result = new Dictionary<string, object?>();
foreach (var prop in value.GetType().GetProperties())
{
if (!prop.CanRead || prop.GetIndexParameters().Length > 0)
continue;
result[prop.Name] = IsSensitive(prop.Name) ? MaskValue : prop.GetValue(value);
}
return result;
}
}
@@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
public interface IAuditLogQueryService
{
Task<PagedResult<AuditLogListItemDto>> GetPagedAsync(AuditLogQuery query);
Task<AuditLogDetailDto?> GetByIdAsync(long id);
AuditCatalogDto GetCatalog();
}
/// <summary>Read-only, paged access to the AuditLogs table via the dedicated LogDbContext.</summary>
public sealed class AuditLogQueryService : IAuditLogQueryService
{
private readonly LogDbContext _db;
public AuditLogQueryService(LogDbContext db) => _db = db;
public async Task<PagedResult<AuditLogListItemDto>> GetPagedAsync(AuditLogQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var rows = _db.AuditLogs.AsNoTracking().AsQueryable();
if (query.From is not null) rows = rows.Where(l => l.Timestamp >= query.From);
if (query.To is not null) rows = rows.Where(l => l.Timestamp <= query.To);
if (query.MinLevel is not null) rows = rows.Where(l => l.Level >= query.MinLevel);
if (!string.IsNullOrWhiteSpace(query.Category)) rows = rows.Where(l => l.Category == query.Category);
if (!string.IsNullOrWhiteSpace(query.Action)) rows = rows.Where(l => l.Action == query.Action);
if (!string.IsNullOrWhiteSpace(query.EntityName)) rows = rows.Where(l => l.EntityName == query.EntityName);
if (!string.IsNullOrWhiteSpace(query.EntityId)) rows = rows.Where(l => l.EntityId == query.EntityId);
if (!string.IsNullOrWhiteSpace(query.UserId)) rows = rows.Where(l => l.UserId == query.UserId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim().ToLower();
rows = rows.Where(l =>
(l.Summary != null && l.Summary.ToLower().Contains(term)) ||
(l.EntityName != null && l.EntityName.ToLower().Contains(term)) ||
(l.UserEmail != null && l.UserEmail.ToLower().Contains(term)));
}
var total = await rows.CountAsync();
var items = await rows
.OrderByDescending(l => l.Timestamp)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(l => new AuditLogListItemDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Action = l.Action,
Category = l.Category,
EntityName = l.EntityName,
EntityId = l.EntityId,
Summary = l.Summary,
UserId = l.UserId,
UserEmail = l.UserEmail,
})
.ToListAsync();
return new PagedResult<AuditLogListItemDto>
{
Items = items, TotalCount = total, Page = page, PageSize = pageSize,
};
}
public async Task<AuditLogDetailDto?> GetByIdAsync(long id)
{
return await _db.AuditLogs.AsNoTracking()
.Where(l => l.Id == id)
.Select(l => new AuditLogDetailDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Action = l.Action,
Category = l.Category,
EntityName = l.EntityName,
EntityId = l.EntityId,
Summary = l.Summary,
UserId = l.UserId,
UserEmail = l.UserEmail,
Changes = l.Changes,
IpAddress = l.IpAddress,
CorrelationId = l.CorrelationId,
})
.FirstOrDefaultAsync();
}
public AuditCatalogDto GetCatalog() => new()
{
Categories = AuditCategories.All,
Actions = AuditActions.All,
Levels = Enum.GetNames<LogLevelEnum>(),
};
}
@@ -0,0 +1,52 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Scoped <see cref="IAuditLogger"/>: fills actor/request context from
/// <see cref="CurrentUserAccessor"/> and enqueues the row onto the shared queue (no direct DB
/// write, so it can't fail a business transaction or recurse through AppDbContext).
/// </summary>
public sealed class AuditLogger : IAuditLogger
{
private readonly SystemLogQueue _queue;
private readonly CurrentUserAccessor _currentUser;
public AuditLogger(SystemLogQueue queue, CurrentUserAccessor currentUser)
{
_queue = queue;
_currentUser = currentUser;
}
public void Write(
string action,
string category,
LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null,
string? entityId = null,
string? summary = null,
object? before = null,
object? after = null,
string? userId = null,
string? userEmail = null,
string? ipAddress = null)
{
var log = new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = level,
Action = action,
Category = category,
EntityName = entityName,
EntityId = entityId,
Summary = summary,
Changes = AuditChangeSerializer.BuildChanges(before, after),
UserId = userId ?? _currentUser.UserId,
UserEmail = userEmail ?? _currentUser.Email,
IpAddress = ipAddress ?? _currentUser.IpAddress,
CorrelationId = _currentUser.CorrelationId,
};
_queue.TryEnqueue(log);
}
}
@@ -0,0 +1,30 @@
using System.Security.Claims;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// One place to resolve the acting user + request context from the current HttpContext, so the
/// "sub" claim quirk (JWT uses NameClaimType="sub" + MapInboundClaims=false, leaving
/// ClaimTypes.NameIdentifier null) lives in a single spot. Used by the audit interceptor,
/// IAuditLogger, the exception middleware, and the timestamp-stamping interceptor.
/// </summary>
public sealed class CurrentUserAccessor
{
private readonly IHttpContextAccessor _http;
public CurrentUserAccessor(IHttpContextAccessor http) => _http = http;
/// <summary>The acting user id, or null when unauthenticated / off the request thread.</summary>
public string? UserId =>
_http.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)
?? _http.HttpContext?.User.FindFirstValue("sub");
/// <summary>The acting user id, or "system" for background/unauthenticated work.</summary>
public string UserIdOrSystem => UserId ?? "system";
public string? Email => _http.HttpContext?.User.FindFirstValue("email");
public string? IpAddress => _http.HttpContext?.Connection.RemoteIpAddress?.ToString();
public string? CorrelationId => _http.HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,27 @@
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Bound from configuration section <c>Logging:Database</c>. Controls what the DB sink persists.
/// </summary>
public sealed class DatabaseLoggerOptions
{
/// <summary>The minimum level actually written to the SystemLogs table. Default: Warning.</summary>
public MsLogLevel MinimumLevel { get; set; } = MsLogLevel.Warning;
/// <summary>
/// Category prefixes never persisted — prevents recursion and log-storms. The DB sink
/// excludes EF/Npgsql (their SQL firehose), the ASP.NET request firehose, and its own
/// writer namespace.
/// </summary>
public string[] ExcludedCategories { get; set; } =
[
"Microsoft.EntityFrameworkCore",
"Npgsql",
"Microsoft.AspNetCore.Hosting.Diagnostics",
"Microsoft.AspNetCore.Routing",
"ROLAC.API.Services.Logging",
"ROLAC.API.Data.Logging",
];
}
@@ -0,0 +1,87 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using ROLAC.API.Entities.Logging;
using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// A singleton <see cref="ILoggerProvider"/> that turns framework/app log events into
/// <see cref="SystemLog"/> rows enqueued onto <see cref="SystemLogQueue"/>. It depends only on
/// singletons (queue, options, IHttpContextAccessor) and NEVER touches a DbContext — that is
/// what makes the enqueue-only design safe from the singleton logging pipeline.
/// </summary>
[ProviderAlias("Database")]
public sealed class DbLoggerProvider : ILoggerProvider
{
private readonly SystemLogQueue _queue;
private readonly DatabaseLoggerOptions _options;
private readonly IHttpContextAccessor _http;
private readonly ConcurrentDictionary<string, DbLogger> _loggers = new();
public DbLoggerProvider(
SystemLogQueue queue, IOptions<DatabaseLoggerOptions> options, IHttpContextAccessor http)
{
_queue = queue;
_options = options.Value;
_http = http;
}
public ILogger CreateLogger(string categoryName) =>
_loggers.GetOrAdd(categoryName, name => new DbLogger(name, _queue, _options, _http));
public void Dispose() => _loggers.Clear();
}
/// <summary>The per-category logger. Drops events below the floor or in excluded categories.</summary>
internal sealed class DbLogger : ILogger
{
private readonly string _category;
private readonly SystemLogQueue _queue;
private readonly DatabaseLoggerOptions _options;
private readonly IHttpContextAccessor _http;
private readonly bool _excluded;
public DbLogger(
string category, SystemLogQueue queue, DatabaseLoggerOptions options, IHttpContextAccessor http)
{
_category = category;
_queue = queue;
_options = options;
_http = http;
_excluded = options.ExcludedCategories.Any(prefix =>
category.StartsWith(prefix, StringComparison.Ordinal));
}
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(MsLogLevel logLevel) =>
!_excluded && logLevel != MsLogLevel.None && logLevel >= _options.MinimumLevel;
public void Log<TState>(
MsLogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
if (!IsEnabled(logLevel))
return;
var context = _http.HttpContext;
var log = new SystemLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelMap.FromMs(logLevel),
Category = _category,
EventId = eventId.Id == 0 ? null : eventId.Id,
Message = formatter(state, exception),
Exception = exception?.ToString(),
RequestPath = context?.Request.Path.Value,
HttpMethod = context?.Request.Method,
UserId = context?.User.FindFirst("sub")?.Value,
IpAddress = context?.Connection.RemoteIpAddress?.ToString(),
CorrelationId = context?.TraceIdentifier,
};
_queue.TryEnqueue(log);
}
}
@@ -0,0 +1,29 @@
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// Records audit events that don't flow through EF change tracking — security actions
/// (login/logout/role changes) and key business actions (check issued, expense approved, ...).
/// Data-change audits are produced automatically by <c>AuditLogInterceptor</c>; use this for the
/// semantic action + human summary the raw diff can't express.
/// </summary>
public interface IAuditLogger
{
/// <summary>
/// Build and enqueue an audit row. <paramref name="before"/>/<paramref name="after"/> are
/// serialized into the <c>Changes</c> JSON. Never throws — failures are dropped like all logs.
/// </summary>
void Write(
string action,
string category,
LogLevelEnum level = LogLevelEnum.Information,
string? entityName = null,
string? entityId = null,
string? summary = null,
object? before = null,
object? after = null,
string? userId = null,
string? userEmail = null,
string? ipAddress = null);
}
@@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// The single consumer that drains <see cref="SystemLogQueue"/> and batch-inserts rows through
/// the dedicated <see cref="LogDbContext"/> (a fresh DI scope per batch). Persistence failures
/// are swallowed to <c>Console.Error</c> only — they must never propagate back into the logging
/// pipeline or crash the host.
/// </summary>
public sealed class LogWriterBackgroundService : BackgroundService
{
private const int MaxBatchSize = 200;
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(1);
private readonly SystemLogQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
public LogWriterBackgroundService(SystemLogQueue queue, IServiceScopeFactory scopeFactory)
{
_queue = queue;
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var systemBatch = new List<SystemLog>(MaxBatchSize);
var auditBatch = new List<AuditLog>(MaxBatchSize);
try
{
await foreach (var envelope in _queue.ReadAllAsync(stoppingToken))
{
if (envelope.System is not null) systemBatch.Add(envelope.System);
if (envelope.Audit is not null) auditBatch.Add(envelope.Audit);
// Coalesce a short burst into one round-trip; flush on size or a brief idle.
if (systemBatch.Count + auditBatch.Count >= MaxBatchSize)
{
await FlushAsync(systemBatch, auditBatch, stoppingToken);
continue;
}
if (!await WaitForMoreAsync(FlushInterval, stoppingToken))
await FlushAsync(systemBatch, auditBatch, stoppingToken);
}
}
catch (OperationCanceledException)
{
// Shutting down — drain whatever is buffered.
}
await FlushAsync(systemBatch, auditBatch, CancellationToken.None);
}
/// <summary>Brief debounce so bursts coalesce; returns false once the window elapses.</summary>
private static async Task<bool> WaitForMoreAsync(TimeSpan window, CancellationToken token)
{
try
{
await Task.Delay(window, token);
return false;
}
catch (OperationCanceledException)
{
return false;
}
}
private async Task FlushAsync(
List<SystemLog> systemBatch, List<AuditLog> auditBatch, CancellationToken token)
{
if (systemBatch.Count == 0 && auditBatch.Count == 0)
return;
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LogDbContext>();
if (systemBatch.Count > 0) db.SystemLogs.AddRange(systemBatch);
if (auditBatch.Count > 0) db.AuditLogs.AddRange(auditBatch);
await db.SaveChangesAsync(token);
}
catch (Exception ex)
{
// Last resort: never throw out of the log writer. Include the inner exception —
// the EF wrapper message alone ("An error occurred while saving...") hides the cause.
var detail = ex.InnerException is null ? ex.Message : $"{ex.Message} -> {ex.InnerException.Message}";
await Console.Error.WriteLineAsync(
$"[LogWriter] Failed to persist {systemBatch.Count} system + {auditBatch.Count} audit rows: {detail}");
}
finally
{
systemBatch.Clear();
auditBatch.Clear();
}
}
}
@@ -0,0 +1,93 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.DTOs.Logging;
using ROLAC.API.DTOs.Shared;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
public interface ISystemLogQueryService
{
Task<PagedResult<SystemLogListItemDto>> GetPagedAsync(SystemLogQuery query);
Task<SystemLogDetailDto?> GetByIdAsync(long id);
}
/// <summary>Read-only, paged access to the SystemLogs table via the dedicated LogDbContext.</summary>
public sealed class SystemLogQueryService : ISystemLogQueryService
{
private readonly LogDbContext _db;
public SystemLogQueryService(LogDbContext db) => _db = db;
public async Task<PagedResult<SystemLogListItemDto>> GetPagedAsync(SystemLogQuery query)
{
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var rows = _db.SystemLogs.AsNoTracking().AsQueryable();
if (query.From is not null) rows = rows.Where(l => l.Timestamp >= query.From);
if (query.To is not null) rows = rows.Where(l => l.Timestamp <= query.To);
if (query.Level is not null) rows = rows.Where(l => l.Level == query.Level);
else if (query.MinLevel is not null) rows = rows.Where(l => l.Level >= query.MinLevel);
if (!string.IsNullOrWhiteSpace(query.UserId))
rows = rows.Where(l => l.UserId == query.UserId);
if (!string.IsNullOrWhiteSpace(query.CorrelationId))
rows = rows.Where(l => l.CorrelationId == query.CorrelationId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim().ToLower();
rows = rows.Where(l =>
l.Message.ToLower().Contains(term) || l.Category.ToLower().Contains(term));
}
var total = await rows.CountAsync();
var items = await rows
.OrderByDescending(l => l.Timestamp)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(l => new SystemLogListItemDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Category = l.Category,
Message = l.Message,
HasException = l.Exception != null,
StatusCode = l.StatusCode,
RequestPath = l.RequestPath,
HttpMethod = l.HttpMethod,
UserId = l.UserId,
CorrelationId = l.CorrelationId,
})
.ToListAsync();
return new PagedResult<SystemLogListItemDto>
{
Items = items, TotalCount = total, Page = page, PageSize = pageSize,
};
}
public async Task<SystemLogDetailDto?> GetByIdAsync(long id)
{
return await _db.SystemLogs.AsNoTracking()
.Where(l => l.Id == id)
.Select(l => new SystemLogDetailDto
{
Id = l.Id,
Timestamp = l.Timestamp,
Level = l.Level.ToString(),
Category = l.Category,
Message = l.Message,
HasException = l.Exception != null,
StatusCode = l.StatusCode,
RequestPath = l.RequestPath,
HttpMethod = l.HttpMethod,
UserId = l.UserId,
CorrelationId = l.CorrelationId,
EventId = l.EventId,
Exception = l.Exception,
IpAddress = l.IpAddress,
})
.FirstOrDefaultAsync();
}
}
@@ -0,0 +1,32 @@
using System.Threading.Channels;
using ROLAC.API.Entities.Logging;
namespace ROLAC.API.Services.Logging;
/// <summary>
/// A singleton, bounded in-memory queue decoupling log producers (the ILogger hot path, the
/// audit interceptor, singleton services) from the single background DB writer. Enqueue is a
/// non-blocking <c>TryWrite</c>; when full the OLDEST entry is dropped (DropWrite) rather than
/// blocking a request thread or throwing — logging must never throw or stall business code.
/// Carries both SystemLog and AuditLog rows via a small union envelope.
/// </summary>
public sealed class SystemLogQueue
{
private readonly Channel<LogEnvelope> _channel =
Channel.CreateBounded<LogEnvelope>(new BoundedChannelOptions(capacity: 4096)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false,
});
public bool TryEnqueue(SystemLog log) => _channel.Writer.TryWrite(new LogEnvelope(log, null));
public bool TryEnqueue(AuditLog log) => _channel.Writer.TryWrite(new LogEnvelope(null, log));
public IAsyncEnumerable<LogEnvelope> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
/// <summary>Either a SystemLog or an AuditLog — exactly one is non-null.</summary>
public sealed record LogEnvelope(SystemLog? System, AuditLog? Audit);
@@ -3,6 +3,8 @@ using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data; using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense; using ROLAC.API.DTOs.Expense;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -10,7 +12,9 @@ public class MonthlyStatementService : IMonthlyStatementService
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IHttpContextAccessor _http; private readonly IHttpContextAccessor _http;
public MonthlyStatementService(AppDbContext db, IHttpContextAccessor http) { _db = db; _http = http; } private readonly IAuditLogger _audit;
public MonthlyStatementService(AppDbContext db, IHttpContextAccessor http, IAuditLogger audit)
{ _db = db; _http = http; _audit = audit; }
// See ExpenseService: the user id lives in the "sub" claim at runtime; NameIdentifier is for tests. // See ExpenseService: the user id lives in the "sub" claim at runtime; NameIdentifier is for tests.
private string CurrentUserId => private string CurrentUserId =>
@@ -66,6 +70,11 @@ public class MonthlyStatementService : IMonthlyStatementService
?? throw new KeyNotFoundException($"MonthlyStatement {id} not found."); ?? throw new KeyNotFoundException($"MonthlyStatement {id} not found.");
s.IsFinalized = true; s.FinalizedAt = DateTimeOffset.UtcNow; s.FinalizedBy = CurrentUserId; s.IsFinalized = true; s.FinalizedAt = DateTimeOffset.UtcNow; s.FinalizedBy = CurrentUserId;
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
_audit.Write(
AuditActions.StatementFinalized, AuditCategories.Business, LogLevelEnum.Information,
entityName: nameof(MonthlyStatement), entityId: s.Id.ToString(),
summary: $"Monthly statement {s.Year}-{s.Month:D2} finalized");
} }
private async Task RecomputeAsync(MonthlyStatement s) private async Task RecomputeAsync(MonthlyStatement s)
+265
View File
@@ -0,0 +1,265 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using ROLAC.API.Authorization;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Permissions;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services;
public interface IPermissionService
{
/// <summary>True if any of the given roles grants the module/action.</summary>
Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action);
/// <summary>Effective permissions for a user (union across roles). super_admin ⇒ all.</summary>
Task<Dictionary<string, ModuleActions>> GetEffectivePermissionsAsync(IEnumerable<string> roles);
/// <summary>Dense matrix (every role × every module) for the admin UI.</summary>
Task<PermissionMatrixDto> GetMatrixAsync();
/// <summary>Replaces a role's grants. Rejects super_admin. Invalidates the cache.</summary>
Task UpsertRoleAsync(string roleName, IEnumerable<ModulePermissionDto> rows);
/// <summary>Drops the cached matrix so the next check rebuilds from the database.</summary>
void Invalidate();
}
/// <summary>
/// Resolves the configurable RBAC matrix. Registered as a singleton; the in-memory
/// snapshot is shared across requests and rebuilt on demand. Database access goes
/// through a scoped <see cref="AppDbContext"/> obtained from <see cref="IServiceScopeFactory"/>.
/// </summary>
public class PermissionService : IPermissionService
{
private const string CacheKey = "rbac:matrix";
private readonly IServiceScopeFactory _scopeFactory;
private readonly IMemoryCache _cache;
private readonly SystemLogQueue _logQueue;
private readonly IHttpContextAccessor _http;
public PermissionService(
IServiceScopeFactory scopeFactory,
IMemoryCache cache,
SystemLogQueue logQueue,
IHttpContextAccessor http)
{
_scopeFactory = scopeFactory;
_cache = cache;
_logQueue = logQueue;
_http = http;
}
public async Task<bool> HasPermissionAsync(IEnumerable<string> roles, string module, string action)
{
var snapshot = await GetSnapshotAsync();
foreach (var role in roles)
{
if (snapshot.TryGetValue(role, out var modules)
&& modules.TryGetValue(module, out var actions)
&& Grants(actions, action))
{
return true;
}
}
return false;
}
public async Task<Dictionary<string, ModuleActions>> GetEffectivePermissionsAsync(IEnumerable<string> roles)
{
var roleList = roles.ToList();
if (roleList.Contains(PermissionAuthorizationHandler.SuperAdminRole))
return AllModulesFull();
var snapshot = await GetSnapshotAsync();
var effective = new Dictionary<string, ModuleActions>();
foreach (var role in roleList)
{
if (!snapshot.TryGetValue(role, out var modules))
continue;
foreach (var (module, actions) in modules)
{
if (!effective.TryGetValue(module, out var merged))
effective[module] = merged = new ModuleActions();
merged.Read |= actions.Read;
merged.Write |= actions.Write;
merged.Delete |= actions.Delete;
merged.Approve |= actions.Approve;
}
}
// Only surface modules the user can actually touch.
return effective.Where(kvp => kvp.Value.Any)
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
}
public async Task<PermissionMatrixDto> GetMatrixAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var roles = await db.Roles.OrderBy(r => r.Name).ToListAsync();
var perms = await db.RolePermissions.ToListAsync();
var byRoleId = perms.ToLookup(p => p.RoleId);
var rows = new List<RolePermissionRow>();
foreach (var role in roles)
{
var isSuperAdmin = role.Name == PermissionAuthorizationHandler.SuperAdminRole;
var existing = byRoleId[role.Id].ToDictionary(p => p.Module);
var moduleRows = new List<ModulePermissionDto>();
foreach (var module in Modules.All)
{
existing.TryGetValue(module, out var rp);
moduleRows.Add(new ModulePermissionDto
{
Module = module,
// super_admin is always-full (display only — never persisted).
CanRead = isSuperAdmin || (rp?.CanRead ?? false),
CanWrite = isSuperAdmin || (rp?.CanWrite ?? false),
CanDelete = isSuperAdmin || (rp?.CanDelete ?? false),
CanApprove = isSuperAdmin || (rp?.CanApprove ?? false),
});
}
rows.Add(new RolePermissionRow
{
RoleName = role.Name!,
Description = role.Description,
IsSuperAdmin = isSuperAdmin,
Modules = moduleRows,
});
}
return new PermissionMatrixDto
{
AllModules = Modules.All,
AllActions = PermissionActions.All,
Roles = rows,
};
}
public async Task UpsertRoleAsync(string roleName, IEnumerable<ModulePermissionDto> rows)
{
if (roleName == PermissionAuthorizationHandler.SuperAdminRole)
throw new InvalidOperationException("super_admin permissions are fixed and cannot be edited.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var role = await db.Roles.FirstOrDefaultAsync(r => r.Name == roleName)
?? throw new KeyNotFoundException($"Role '{roleName}' not found.");
var existing = await db.RolePermissions
.Where(p => p.RoleId == role.Id)
.ToListAsync();
db.RolePermissions.RemoveRange(existing);
foreach (var row in rows)
{
if (!Modules.IsValid(row.Module))
continue;
if (!(row.CanRead || row.CanWrite || row.CanDelete || row.CanApprove))
continue; // don't store all-false rows
db.RolePermissions.Add(new RolePermission
{
RoleId = role.Id,
Module = row.Module,
CanRead = row.CanRead,
CanWrite = row.CanWrite,
CanDelete = row.CanDelete,
CanApprove = row.CanApprove,
});
}
await db.SaveChangesAsync();
Invalidate();
// Singleton service can't use the scoped IAuditLogger — enqueue directly.
var user = _http.HttpContext?.User;
_logQueue.TryEnqueue(new AuditLog
{
Timestamp = DateTimeOffset.UtcNow,
Level = LogLevelEnum.Warning,
Action = AuditActions.PermissionChanged,
Category = AuditCategories.Security,
EntityName = "Role",
EntityId = roleName,
Summary = $"Permissions updated for role '{roleName}'",
Changes = AuditChangeSerializer.BuildChanges(null, new { Role = roleName, Modules = rows }),
UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier) ?? user?.FindFirstValue("sub"),
UserEmail = user?.FindFirstValue("email"),
IpAddress = _http.HttpContext?.Connection.RemoteIpAddress?.ToString(),
CorrelationId = _http.HttpContext?.TraceIdentifier,
});
}
public void Invalidate() => _cache.Remove(CacheKey);
// ── Internals ────────────────────────────────────────────────────────────
/// <summary>roleName → (module → ModuleActions). Cached until invalidated.</summary>
private async Task<Dictionary<string, Dictionary<string, ModuleActions>>> GetSnapshotAsync()
{
if (_cache.TryGetValue(CacheKey, out Dictionary<string, Dictionary<string, ModuleActions>>? cached)
&& cached is not null)
{
return cached;
}
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var rows = await (
from rp in db.RolePermissions
join r in db.Roles on rp.RoleId equals r.Id
select new { RoleName = r.Name!, rp.Module, rp.CanRead, rp.CanWrite, rp.CanDelete, rp.CanApprove }
).ToListAsync();
var snapshot = new Dictionary<string, Dictionary<string, ModuleActions>>();
foreach (var row in rows)
{
if (!snapshot.TryGetValue(row.RoleName, out var modules))
snapshot[row.RoleName] = modules = new Dictionary<string, ModuleActions>();
modules[row.Module] = new ModuleActions
{
Read = row.CanRead,
Write = row.CanWrite,
Delete = row.CanDelete,
Approve = row.CanApprove,
};
}
_cache.Set(CacheKey, snapshot);
return snapshot;
}
private static bool Grants(ModuleActions actions, string action) => action switch
{
PermissionActions.Read => actions.Read,
PermissionActions.Write => actions.Write,
PermissionActions.Delete => actions.Delete,
PermissionActions.Approve => actions.Approve,
_ => false,
};
private static Dictionary<string, ModuleActions> AllModulesFull()
{
var all = new Dictionary<string, ModuleActions>();
foreach (var module in Modules.All)
all[module] = new ModuleActions { Read = true, Write = true, Delete = true, Approve = true };
return all;
}
}
@@ -5,6 +5,8 @@ using ROLAC.API.Data;
using ROLAC.API.DTOs.Shared; using ROLAC.API.DTOs.Shared;
using ROLAC.API.DTOs.Users; using ROLAC.API.DTOs.Users;
using ROLAC.API.Entities; using ROLAC.API.Entities;
using ROLAC.API.Entities.Logging;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services; namespace ROLAC.API.Services;
@@ -12,11 +14,13 @@ public class UserManagementService : IUserManagementService
{ {
private readonly UserManager<AppUser> _userManager; private readonly UserManager<AppUser> _userManager;
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IAuditLogger _audit;
public UserManagementService(UserManager<AppUser> userManager, AppDbContext db) public UserManagementService(UserManager<AppUser> userManager, AppDbContext db, IAuditLogger audit)
{ {
_userManager = userManager; _userManager = userManager;
_db = db; _db = db;
_audit = audit;
} }
// ── GetPaged ───────────────────────────────────────────────────────────── // ── GetPaged ─────────────────────────────────────────────────────────────
@@ -154,6 +158,12 @@ public class UserManagementService : IUserManagementService
await _userManager.AddToRolesAsync(user, request.Roles); await _userManager.AddToRolesAsync(user, request.Roles);
_audit.Write(
AuditActions.RoleChanged, AuditCategories.Security, LogLevelEnum.Warning,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"User created: {user.Email} with roles [{string.Join(", ", request.Roles)}]",
after: new { user.Email, Roles = request.Roles });
return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword }; return new CreateUserResult { UserId = user.Id, TempPassword = tempPassword };
} }
@@ -182,6 +192,13 @@ public class UserManagementService : IUserManagementService
var toAdd = request.Roles.Except(currentRoles).ToList(); var toAdd = request.Roles.Except(currentRoles).ToList();
if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove); if (toRemove.Count > 0) await _userManager.RemoveFromRolesAsync(user, toRemove);
if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd); if (toAdd.Count > 0) await _userManager.AddToRolesAsync(user, toAdd);
if (toRemove.Count > 0 || toAdd.Count > 0)
_audit.Write(
AuditActions.RoleChanged, AuditCategories.Security, LogLevelEnum.Warning,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Roles changed for {user.Email}",
before: new { Roles = currentRoles }, after: new { Roles = request.Roles });
} }
// ── Deactivate ─────────────────────────────────────────────────────────── // ── Deactivate ───────────────────────────────────────────────────────────
@@ -193,6 +210,11 @@ public class UserManagementService : IUserManagementService
user.IsActive = false; user.IsActive = false;
user.LockoutEnd = DateTimeOffset.MaxValue; user.LockoutEnd = DateTimeOffset.MaxValue;
await _userManager.UpdateAsync(user); await _userManager.UpdateAsync(user);
_audit.Write(
AuditActions.UserDeactivated, AuditCategories.Security, LogLevelEnum.Warning,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"User deactivated: {user.Email}");
} }
// ── ResetPassword ──────────────────────────────────────────────────────── // ── ResetPassword ────────────────────────────────────────────────────────
+11
View File
@@ -3,6 +3,17 @@
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
},
"Database": {
"MinimumLevel": "Warning",
"ExcludedCategories": [
"Microsoft.EntityFrameworkCore",
"Npgsql",
"Microsoft.AspNetCore.Hosting.Diagnostics",
"Microsoft.AspNetCore.Routing",
"ROLAC.API.Services.Logging",
"ROLAC.API.Data.Logging"
]
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
+106 -26
View File
@@ -3,7 +3,9 @@ import { DashboardComponent } from './portals/user-portal/pages/dashboard/dashbo
import { LoginPage } from './features/login-page/login-page'; import { LoginPage } from './features/login-page/login-page';
import { UserPortalComponent } from './portals/user-portal/user-portal.component'; import { UserPortalComponent } from './portals/user-portal/user-portal.component';
import { AuthGuard } from './core/guards/auth.guard'; import { AuthGuard } from './core/guards/auth.guard';
import { RoleGuard } from './core/guards/role.guard'; import { PermissionGuard } from './core/guards/permission.guard';
import { PermissionModules } from './core/models/permission.model';
import { PermissionsPageComponent } from './features/permissions/pages/permissions-page/permissions-page.component';
import { MembersPageComponent } from './features/members/pages/members-page/members-page.component'; import { MembersPageComponent } from './features/members/pages/members-page/members-page.component';
import { UsersPageComponent } from './features/users/pages/users-page/users-page.component'; import { UsersPageComponent } from './features/users/pages/users-page/users-page.component';
import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component'; import { GivingCategoriesPageComponent } from './features/giving/pages/giving-categories-page/giving-categories-page.component';
@@ -19,6 +21,8 @@ import { CheckRegisterPageComponent } from './features/disbursement/pages/check-
import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component'; import { ChurchProfilePageComponent } from './features/disbursement/pages/church-profile-page/church-profile-page.component';
import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component'; import { AttendanceCounterPageComponent } from './features/meal-attendance/pages/attendance-counter-page/attendance-counter-page.component';
import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component'; import { OfferingEntryMobilePageComponent } from './features/giving/pages/offering-entry-mobile-page/offering-entry-mobile-page.component';
import { SystemLogsPageComponent } from './features/logging/pages/system-logs-page/system-logs-page.component';
import { AuditLogsPageComponent } from './features/logging/pages/audit-logs-page/audit-logs-page.component';
export const routes: Routes = [ export const routes: Routes = [
// Public routes // Public routes
@@ -37,74 +41,150 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [ children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent }, {
{ path: 'admin/members', component: MembersPageComponent }, path: 'dashboard',
component: DashboardComponent,
data: { title: 'Dashboard', titleZh: '首頁', section: 'Home' },
},
{
path: 'admin/members',
component: MembersPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.Members, action: 'read' },
title: 'Member Management', titleZh: '會友管理', section: 'Admin',
},
},
{ {
path: 'admin/users', path: 'admin/users',
component: UsersPageComponent, component: UsersPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['super_admin'] }, data: {
permission: { module: PermissionModules.Users, action: 'read' },
title: 'User Management', titleZh: '使用者管理', section: 'Admin',
},
},
{
path: 'admin/permissions',
component: PermissionsPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.Permissions, action: 'read' },
title: 'Role Permissions', titleZh: '權限設定', section: 'Admin',
},
},
{
path: 'admin/logs/system',
component: SystemLogsPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.SystemLogs, action: 'read' },
title: 'System Logs', titleZh: '系統日誌', section: 'Admin',
},
},
{
path: 'admin/logs/audit',
component: AuditLogsPageComponent,
canActivate: [PermissionGuard],
data: {
permission: { module: PermissionModules.AuditLogs, action: 'read' },
title: 'Audit Logs', titleZh: '稽核日誌', section: 'Admin',
},
}, },
{ {
path: 'finance/dashboard', path: 'finance/dashboard',
component: FinanceDashboardPageComponent, component: FinanceDashboardPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.FinanceDashboard, action: 'read' },
title: 'Finance Dashboard', titleZh: '財務儀表板', section: 'Finance',
},
}, },
{ {
path: 'finance/giving-categories', path: 'finance/giving-categories',
component: GivingCategoriesPageComponent, component: GivingCategoriesPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.GivingCategories, action: 'read' },
title: 'Giving Types', titleZh: '奉獻類型', section: 'Finance',
},
}, },
{ {
path: 'finance/givings', path: 'finance/givings',
component: GivingsPageComponent, component: GivingsPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.Givings, action: 'read' },
title: 'Givings', titleZh: '單筆奉獻', section: 'Finance',
},
}, },
{ {
path: 'finance/offering-session', path: 'finance/offering-session',
component: OfferingSessionPageComponent, component: OfferingSessionPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.OfferingSessions, action: 'read' },
title: 'Sunday Offering Entry', titleZh: '主日奉獻錄入', section: 'Finance',
},
},
{
path: 'reimbursements',
component: MyReimbursementsPageComponent,
data: { title: 'My Reimbursements', titleZh: '我的報銷', section: 'Finance' },
}, },
{ path: 'reimbursements', component: MyReimbursementsPageComponent },
{ {
path: 'finance/expenses', path: 'finance/expenses',
component: ExpensesPageComponent, component: ExpensesPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.Expenses, action: 'read' },
title: 'Expenses', titleZh: '支出', section: 'Finance',
},
}, },
{ {
path: 'finance/expense-categories', path: 'finance/expense-categories',
component: ExpenseCategoriesPageComponent, component: ExpenseCategoriesPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.ExpenseCategories, action: 'read' },
title: 'Expense Categories', titleZh: '費用類別', section: 'Finance',
},
}, },
{ {
path: 'finance/monthly-statement', path: 'finance/monthly-statement',
component: MonthlyStatementPageComponent, component: MonthlyStatementPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.MonthlyStatements, action: 'read' },
title: 'Monthly Statement', titleZh: '月報表', section: 'Finance',
},
}, },
{ {
path: 'finance/disbursements', path: 'finance/disbursements',
component: DisbursementPageComponent, component: DisbursementPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.Disbursements, action: 'read' },
title: 'Disbursement Management', titleZh: '支票開立', section: 'Finance',
},
}, },
{ {
path: 'finance/check-register', path: 'finance/check-register',
component: CheckRegisterPageComponent, component: CheckRegisterPageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.Disbursements, action: 'read' },
title: 'Check Register', titleZh: '支票登記簿', section: 'Finance',
},
}, },
{ {
path: 'finance/church-profile', path: 'finance/church-profile',
component: ChurchProfilePageComponent, component: ChurchProfilePageComponent,
canActivate: [RoleGuard], canActivate: [PermissionGuard],
data: { roles: ['finance', 'super_admin'] }, data: {
permission: { module: PermissionModules.ChurchProfile, action: 'read' },
title: 'Church Profile', titleZh: '教會資料', section: 'Finance',
},
}, },
] ]
}, },
@@ -0,0 +1,66 @@
import { Directive, Input, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
import { AuthService } from '../../shared/services/auth.service';
import { PermissionService } from '../services/permission.service';
import { PermissionAction, PermissionRequirement } from '../models/permission.model';
/**
* Structural directive that renders its content only if the current user has the
* required permission. Re-evaluates when the current user changes (e.g. after a
* matrix edit + refresh). Usage:
*
* <button *appHasPermission="{ module: 'Expenses', action: 'write' }">Edit</button>
* <button *appHasPermission="['Expenses', 'approve']">Approve</button>
*/
@Directive({
selector: '[appHasPermission]',
standalone: true,
})
export class HasPermissionDirective implements OnInit, OnDestroy {
private requirement: PermissionRequirement | null = null;
private hasView = false;
private destroy$ = new Subject<void>();
constructor(
private templateRef: TemplateRef<unknown>,
private viewContainer: ViewContainerRef,
private permissions: PermissionService,
private auth: AuthService
) { }
@Input()
set appHasPermission(value: PermissionRequirement | [string, PermissionAction]) {
if (Array.isArray(value)) {
this.requirement = { module: value[0], action: value[1] };
} else {
this.requirement = value;
}
this.updateView();
}
ngOnInit(): void {
// React to login/logout/refresh so visibility stays in sync with permissions.
this.auth.currentUser$
.pipe(takeUntil(this.destroy$))
.subscribe(() => this.updateView());
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private updateView(): void {
const allowed = this.requirement
? this.permissions.can(this.requirement.module, this.requirement.action)
: false;
if (allowed && !this.hasView) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.hasView = true;
} else if (!allowed && this.hasView) {
this.viewContainer.clear();
this.hasView = false;
}
}
}
@@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router } from '@angular/router';
import { AuthService } from '../../shared/services/auth.service';
import { PermissionService } from '../services/permission.service';
import { PermissionRequirement } from '../models/permission.model';
/**
* Route guard for the configurable permission system. Reads
* route.data['permission'] = { module, action } and blocks navigation if the
* current user lacks it (redirecting to the dashboard). The parent route's
* AuthGuard guarantees the session is restored before children activate.
*/
@Injectable({ providedIn: 'root' })
export class PermissionGuard implements CanActivate {
constructor(
private permissions: PermissionService,
private auth: AuthService,
private router: Router
) { }
canActivate(route: ActivatedRouteSnapshot): boolean {
const required = route.data['permission'] as PermissionRequirement | undefined;
if (!required) {
return true;
}
const allowed = this.permissions.can(required.module, required.action);
if (!allowed) {
this.router.navigate(['/user-portal/dashboard']);
}
return allowed;
}
}
@@ -0,0 +1,67 @@
/** Effective action flags for one module — mirrors the C# ModuleActions DTO. */
export interface ModuleActions {
read: boolean;
write: boolean;
delete: boolean;
approve: boolean;
/** Computed server-side (true if any flag is set). */
any?: boolean;
}
export type PermissionAction = 'read' | 'write' | 'delete' | 'approve';
/**
* Canonical module names — must match the C# ROLAC.API.Authorization.Modules constants
* (PascalCase). Used by the permission directive, guard, nav, and admin page.
*/
export const PermissionModules = {
Members: 'Members',
Users: 'Users',
Givings: 'Givings',
GivingCategories: 'GivingCategories',
Expenses: 'Expenses',
ExpenseCategories: 'ExpenseCategories',
OfferingSessions: 'OfferingSessions',
Ministries: 'Ministries',
FinanceDashboard: 'FinanceDashboard',
MonthlyStatements: 'MonthlyStatements',
ChurchProfile: 'ChurchProfile',
Disbursements: 'Disbursements',
MealAttendance: 'MealAttendance',
Permissions: 'Permissions',
SystemLogs: 'SystemLogs',
AuditLogs: 'AuditLogs',
} as const;
/** A required permission, used in route data and the *appHasPermission directive. */
export interface PermissionRequirement {
module: string;
action: PermissionAction;
}
// ── Admin matrix DTOs (mirror C# DTOs.Permissions) ────────────────────────────
export interface ModulePermissionDto {
module: string;
canRead: boolean;
canWrite: boolean;
canDelete: boolean;
canApprove: boolean;
}
export interface RolePermissionRow {
roleName: string;
description?: string;
isSuperAdmin: boolean;
modules: ModulePermissionDto[];
}
export interface PermissionMatrixDto {
allModules: string[];
allActions: string[];
roles: RolePermissionRow[];
}
export interface UpdateRolePermissionsRequest {
modules: ModulePermissionDto[];
}
@@ -0,0 +1,65 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { AuthService, UserInfo } from '../../shared/services/auth.service';
import { ApiConfigService } from './api-config.service';
import { PermissionAction } from '../models/permission.model';
const SUPER_ADMIN = 'super_admin';
/**
* Reads the current user's effective permissions (delivered on the UserInfo payload)
* and answers can(module, action). super_admin always passes. This is a UX mirror —
* the backend remains the authoritative permission boundary.
*/
@Injectable({ providedIn: 'root' })
export class PermissionService {
constructor(
private auth: AuthService,
private http: HttpClient,
private apiConfig: ApiConfigService
) { }
/** True if the current user may perform <action> on <module>. */
can(module: string, action: PermissionAction): boolean {
const user = this.auth.getCurrentUser();
if (!user) {
return false;
}
if (user.roles?.includes(SUPER_ADMIN)) {
return true;
}
const moduleActions = user.permissions?.[this.normalizeKey(module)];
return !!moduleActions && !!moduleActions[action];
}
canRead(module: string): boolean { return this.can(module, 'read'); }
canWrite(module: string): boolean { return this.can(module, 'write'); }
canDelete(module: string): boolean { return this.can(module, 'delete'); }
canApprove(module: string): boolean { return this.can(module, 'approve'); }
/**
* Re-fetches the current user (with fresh permissions) from GET /api/auth/me.
* Call after an admin edits the matrix so the UI reflects the change without
* a re-login. Returns the updated user, or null on failure.
*/
refresh(): Observable<UserInfo | null> {
return this.http.get<UserInfo>(`${this.apiConfig.authUrl}/me`).pipe(
tap(user => this.auth.setCurrentUser(user)),
map(user => user),
catchError(() => of(null))
);
}
/**
* Module names are stored PascalCase in code but arrive as camelCase dictionary
* keys (server's DictionaryKeyPolicy). Lowercase the first character to match.
*/
private normalizeKey(module: string): string {
if (!module) {
return module;
}
return module.charAt(0).toLowerCase() + module.slice(1);
}
}
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Check Register / 支票登記簿</h2>
</header>
<div class="flex flex-wrap gap-3 items-end mb-4"> <div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
Search Search
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Church Profile / 教會資料</h2>
</header>
<div *ngIf="model" class="max-w-3xl"> <div *ngIf="model" class="max-w-3xl">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
<label class="flex flex-col gap-1 md:col-span-2"> <label class="flex flex-col gap-1 md:col-span-2">
@@ -1,10 +1,9 @@
<div class="page"> <div class="page">
<header class="page-header flex items-center justify-between"> <ng-template appPageHeaderActions>
<h2>Disbursement Management / 支票開立</h2>
<button kendoButton themeColor="primary" [disabled]="selectedCount === 0" (click)="openIssue()"> <button kendoButton themeColor="primary" [disabled]="selectedCount === 0" (click)="openIssue()">
Issue Checks ({{ selectedCount }}) Issue Checks ({{ selectedCount }})
</button> </button>
</header> </ng-template>
<p class="text-sm mb-3" style="color:#6b7280;"> <p class="text-sm mb-3" style="color:#6b7280;">
Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each. Approved expenses awaiting payment, grouped by payee. Select payees and issue one check each.
@@ -1,3 +0,0 @@
.page-header {
margin-bottom: 0.5rem;
}
@@ -6,13 +6,14 @@ import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { DisbursementApiService } from '../../services/disbursement-api.service'; import { DisbursementApiService } from '../../services/disbursement-api.service';
import { IssueCheckDialogComponent } from '../../components/issue-check-dialog/issue-check-dialog.component'; import { IssueCheckDialogComponent } from '../../components/issue-check-dialog/issue-check-dialog.component';
import { PayeeGroupDto, IssueChecksRequest } from '../../models/disbursement.model'; import { PayeeGroupDto, IssueChecksRequest } from '../../models/disbursement.model';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
interface PayeeRow extends PayeeGroupDto { key: string; } interface PayeeRow extends PayeeGroupDto { key: string; }
@Component({ @Component({
selector: 'app-disbursement-page', selector: 'app-disbursement-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent], imports: [CommonModule, FormsModule, GridModule, ButtonsModule, IssueCheckDialogComponent, PageHeaderActionsDirective],
templateUrl: './disbursement-page.component.html', templateUrl: './disbursement-page.component.html',
styleUrls: ['./disbursement-page.component.scss'], styleUrls: ['./disbursement-page.component.scss'],
}) })
@@ -85,10 +85,16 @@
<!-- Reimbursement mode: receipt file input --> <!-- Reimbursement mode: receipt file input -->
<ng-container *ngIf="mode === 'reimbursement'"> <ng-container *ngIf="mode === 'reimbursement'">
<label class="flex flex-col gap-1 md:col-span-2">Receipt (optional) <label class="flex flex-col gap-1 md:col-span-2">Receipt (optional)
<!--
Stop the native 'cancel' DOM event (fired when the OS file picker is dismissed)
from bubbling up to the host, where it would collide with this component's
@Output() cancel and wrongly close the dialog. See Angular issues #50556 / #13997.
-->
<input <input
type="file" type="file"
accept="image/*,application/pdf" accept="image/*,application/pdf"
(change)="onFileSelected($event)" (change)="onFileSelected($event)"
(cancel)="$event.stopPropagation()"
class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" /> class="block w-full text-sm text-gray-700 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-gray-100 hover:file:bg-gray-200" />
</label> </label>
</ng-container> </ng-container>
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Expense Categories / 費用類別</h2>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Left: Category Groups --> <!-- Left: Category Groups -->
@@ -1,10 +1,3 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.panel-header { .panel-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Expenses</h2>
</header>
<!-- Filter toolbar --> <!-- Filter toolbar -->
<div class="flex flex-wrap gap-3 items-end mb-4"> <div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
@@ -1,8 +1,4 @@
<div class="page"> <div class="page">
<header class="page-header">
<h2>Monthly Statements</h2>
</header>
<!-- Filter toolbar --> <!-- Filter toolbar -->
<div class="flex flex-wrap gap-3 items-end mb-4"> <div class="flex flex-wrap gap-3 items-end mb-4">
<label class="flex flex-col gap-1"> <label class="flex flex-col gap-1">
@@ -1,8 +1,7 @@
<div class="page"> <div class="page">
<header class="page-header"> <ng-template appPageHeaderActions>
<h2>My Reimbursements</h2>
<button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button> <button kendoButton themeColor="primary" (click)="openNew()">+ New Reimbursement</button>
</header> </ng-template>
<kendo-grid [data]="rows" [loading]="loading"> <kendo-grid [data]="rows" [loading]="loading">
<kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column> <kendo-grid-column field="expenseDate" title="Date" [width]="110"></kendo-grid-column>
@@ -6,11 +6,12 @@ import { ExpenseApiService } from '../../services/expense-api.service';
import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component'; import { ExpenseFormDialogComponent, ExpenseFormResult } from '../../components/expense-form-dialog/expense-form-dialog.component';
import { ExpenseListItemDto } from '../../models/expense.model'; import { ExpenseListItemDto } from '../../models/expense.model';
import { switchMap, of } from 'rxjs'; import { switchMap, of } from 'rxjs';
import { PageHeaderActionsDirective } from '../../../../shared/directives/page-header-actions.directive';
@Component({ @Component({
selector: 'app-my-reimbursements-page', selector: 'app-my-reimbursements-page',
standalone: true, standalone: true,
imports: [CommonModule, GridModule, ButtonsModule, ExpenseFormDialogComponent], imports: [CommonModule, GridModule, ButtonsModule, ExpenseFormDialogComponent, PageHeaderActionsDirective],
templateUrl: './my-reimbursements-page.component.html', templateUrl: './my-reimbursements-page.component.html',
styleUrls: ['./my-reimbursements-page.component.scss'], styleUrls: ['./my-reimbursements-page.component.scss'],
}) })
@@ -1,11 +1,19 @@
<div class="fin"> <div class="fin">
<!-- Page header --> <!-- Range filter — projected into the shared system header's right slot -->
<header class="fin__head"> <ng-template appPageHeaderActions>
<div> <div class="chips">
<span class="fin__eyebrow">River of Life · Finance</span> <button type="button" class="chip" [class.is-active]="activeRange === 'month'" (click)="setQuickRange('month')">This Month</button>
<h1 class="fin__title">Finance Dashboard <span>財務儀表板</span></h1> <button type="button" class="chip" [class.is-active]="activeRange === 'lastMonth'" (click)="setQuickRange('lastMonth')">Last Month</button>
<button type="button" class="chip" [class.is-active]="activeRange === 'year'" (click)="setQuickRange('year')">This Year</button>
</div> </div>
</header> <div class="range">
<kendo-datepicker [(ngModel)]="from" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'From date' }"></kendo-datepicker>
<span class="range__sep"></span>
<kendo-datepicker [(ngModel)]="to" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'To date' }"></kendo-datepicker>
</div>
</ng-template>
<!-- Top band: hero balance + supporting stats (all-time) --> <!-- Top band: hero balance + supporting stats (all-time) -->
<section class="fin__band rise" style="--d: 0ms"> <section class="fin__band rise" style="--d: 0ms">
@@ -36,22 +44,6 @@
</div> </div>
</section> </section>
<!-- Range filter -->
<section class="fin__filter rise" style="--d: 80ms">
<div class="chips">
<button type="button" class="chip" [class.is-active]="activeRange === 'month'" (click)="setQuickRange('month')">This Month</button>
<button type="button" class="chip" [class.is-active]="activeRange === 'lastMonth'" (click)="setQuickRange('lastMonth')">Last Month</button>
<button type="button" class="chip" [class.is-active]="activeRange === 'year'" (click)="setQuickRange('year')">This Year</button>
</div>
<div class="range">
<kendo-datepicker [(ngModel)]="from" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'From date' }"></kendo-datepicker>
<span class="range__sep"></span>
<kendo-datepicker [(ngModel)]="to" (valueChange)="onManualDateChange()" [fillMode]="'flat'"
[inputAttributes]="{ 'aria-label': 'To date' }"></kendo-datepicker>
</div>
</section>
<!-- Analytics grid --> <!-- Analytics grid -->
<section class="fin__grid"> <section class="fin__grid">
<!-- 2.1 Income vs Expense --> <!-- 2.1 Income vs Expense -->

Some files were not shown because too many files have changed in this diff Show More