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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
namespace FSH.Modules.Identity.Authorization;

/// <summary>
/// Runs once on host startup: iterates every tenant and adds any permission claims that
/// have been registered via <see cref="FSH.Framework.Shared.Constants.PermissionConstants"/>
/// but are missing from the role claims table for that tenant. Idempotent and lightweight —
/// only writes when there's something new, so it's safe to run unconditionally.
/// Runs once on host startup: iterates every tenant and reconciles the built-in roles' permission
/// claims with the ones registered via <see cref="FSH.Framework.Shared.Constants.PermissionConstants"/>,
/// adding the missing ones and removing the ones no longer granted. Idempotent and lightweight —
/// only writes when something changed, so it's safe to run unconditionally.
/// </summary>
/// <remarks>
/// Implemented as a <see cref="BackgroundService"/> so it does not block host startup.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
namespace FSH.Modules.Identity.Authorization;

/// <summary>
/// Adds missing permission claims to the built-in roles (<see cref="RoleConstants.Admin"/>,
/// <see cref="RoleConstants.Basic"/>) for the current Finbuckle tenant context. Idempotent —
/// only inserts claims that don't already exist, so it can run on every startup safely.
/// Reconciles the permission claims of the built-in roles (<see cref="RoleConstants.Admin"/>,
/// <see cref="RoleConstants.Basic"/>) with the permission catalog for the current Finbuckle tenant
/// context: adds the missing ones and removes the ones the catalog no longer grants. Authoritative
/// because those roles are locked against manual edits, so no operator change can be lost.
/// Idempotent, so it can run on every startup safely.
/// </summary>
public sealed class RolePermissionSyncer(
IdentityDbContext context,
Expand All @@ -30,17 +32,17 @@ public async Task SyncAsync(CancellationToken cancellationToken)
var tenantId = tenantAccessor.MultiTenantContext.TenantInfo?.Id;
bool isRoot = tenantId == MultitenancyConstants.Root.Id;

int basicAdded = await SyncRoleAsync(RoleConstants.Basic, PermissionConstants.Basic, cancellationToken).ConfigureAwait(false);
int basicChanged = await SyncRoleAsync(RoleConstants.Basic, PermissionConstants.Basic, cancellationToken).ConfigureAwait(false);

// Admin gets all non-root permissions; the root tenant's Admin additionally gets Root permissions.
var adminPermissions = isRoot
? PermissionConstants.Admin.Concat(PermissionConstants.Root).Distinct().ToList()
: PermissionConstants.Admin.ToList();
int adminAdded = await SyncRoleAsync(RoleConstants.Admin, adminPermissions, cancellationToken).ConfigureAwait(false);
int adminChanged = await SyncRoleAsync(RoleConstants.Admin, adminPermissions, cancellationToken).ConfigureAwait(false);

// If we wrote anything, drop the per-user permission cache so already-logged-in
// sessions see the new perms on their next request rather than waiting for TTL.
if (basicAdded + adminAdded > 0)
// sessions see the change on their next request rather than waiting for TTL.
if (basicChanged + adminChanged > 0)
{
await cache.RemoveByTagAsync(CacheKeys.Tags.Permissions, cancellationToken).ConfigureAwait(false);
}
Expand All @@ -59,10 +61,10 @@ private async Task<int> SyncRoleAsync(string roleName, IReadOnlyList<FshPermissi

var existing = await context.RoleClaims
.Where(rc => rc.RoleId == role.Id && rc.ClaimType == ClaimConstants.Permission)
.Select(rc => rc.ClaimValue!)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var existingSet = existing.ToHashSet(StringComparer.Ordinal);
var existingSet = existing.Select(rc => rc.ClaimValue).ToHashSet(StringComparer.Ordinal);
var targetSet = targetPermissions.Select(p => p.Name).ToHashSet(StringComparer.Ordinal);

var toAdd = targetPermissions
.Where(p => !existingSet.Contains(p.Name))
Expand All @@ -76,15 +78,33 @@ private async Task<int> SyncRoleAsync(string roleName, IReadOnlyList<FshPermissi
})
.ToList();

if (toAdd.Count == 0)
// A null value grants nothing, so it is as stale as a retired permission.
var toRemove = existing
.Where(rc => rc.ClaimValue is null || !targetSet.Contains(rc.ClaimValue))
.ToList();

if (toAdd.Count == 0 && toRemove.Count == 0)
{
return 0;
}

context.RoleClaims.RemoveRange(toRemove);
await context.RoleClaims.AddRangeAsync(toAdd, cancellationToken).ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);

if (logger.IsEnabled(LogLevel.Information))
if (toRemove.Count > 0 && logger.IsEnabled(LogLevel.Warning))
{
foreach (var claim in toRemove)
{
logger.LogWarning(
"Removed permission claim '{Permission}' from '{Role}' for tenant '{Tenant}': the permission catalog no longer grants it",
claim.ClaimValue,
roleName,
tenantAccessor.MultiTenantContext.TenantInfo?.Id);
}
}

if (toAdd.Count > 0 && logger.IsEnabled(LogLevel.Information))
{
logger.LogInformation(
"Synced {Count} new permission claim(s) to '{Role}' for tenant '{Tenant}'",
Expand All @@ -93,6 +113,6 @@ private async Task<int> SyncRoleAsync(string roleName, IReadOnlyList<FshPermissi
tenantAccessor.MultiTenantContext.TenantInfo?.Id);
}

return toAdd.Count;
return toAdd.Count + toRemove.Count;
}
}
109 changes: 99 additions & 10 deletions src/Tests/Integration.Tests/Tests/Catalog/RolePermissionSyncerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Catalog.Contracts.Authorization;
using FSH.Modules.Identity.Authorization;
using FSH.Modules.Identity.Contracts.Authorization;
using FSH.Modules.Identity.Data;
using FSH.Modules.Identity.Domain;
using Integration.Tests.Infrastructure;
Expand All @@ -18,11 +19,16 @@ namespace Integration.Tests.Tests.Catalog;
/// "permissions added in a release after tenant was already provisioned" path —
/// which the per-tenant <c>IDbInitializer.SeedAsync</c> flow does not run again
/// for already-provisioned tenants in production. This is the path that produced
/// 401s in dev when the Catalog module was added.
/// 401s in dev when the Catalog module was added. Also verifies the reverse path:
/// a permission the catalog stops granting is revoked from the built-in roles.
/// </summary>
[Collection(FshCollectionDefinition.Name)]
public sealed class RolePermissionSyncerTests
{
// Well-formed but never registered: stands in for a permission a release removed from the catalog.
private const string RetiredPermission = "Permissions.Retired.View";
private const string NonPermissionClaimType = "fsh.test.marker";

private readonly FshWebApplicationFactory _factory;

public RolePermissionSyncerTests(FshWebApplicationFactory factory)
Expand All @@ -39,9 +45,9 @@ public async Task SyncAsync_Should_Restore_Missing_Permission_Claims_For_Admin_R

// 1. Wipe Admin's Catalog.* claims directly. Simulates "perms registered in a
// later release that never made it into this tenant's RoleClaims table."
await WipeClaimsAsync(rootTenant, "Admin", catalogPermissions);
await WipeClaimsAsync(rootTenant, "Admin", ClaimConstants.Permission, catalogPermissions);

var afterWipe = await GetClaimsAsync(rootTenant, "Admin");
var afterWipe = await GetClaimsAsync(rootTenant, "Admin", ClaimConstants.Permission);
afterWipe.Intersect(catalogPermissions).ShouldBeEmpty(
"Pre-condition: Admin must not have any Catalog claims after the wipe.");

Expand All @@ -56,7 +62,7 @@ public async Task SyncAsync_Should_Restore_Missing_Permission_Claims_For_Admin_R
}

// 3. All Catalog perms should be back on the Admin role.
var afterSync = await GetClaimsAsync(rootTenant, "Admin");
var afterSync = await GetClaimsAsync(rootTenant, "Admin", ClaimConstants.Permission);
var missing = catalogPermissions.Where(p => !afterSync.Contains(p)).ToList();
missing.ShouldBeEmpty(
$"Syncer failed to restore {missing.Count} catalog permission(s): " +
Expand All @@ -67,7 +73,7 @@ public async Task SyncAsync_Should_Restore_Missing_Permission_Claims_For_Admin_R
public async Task SyncAsync_Should_Be_Idempotent_When_Claims_Already_Exist()
{
var rootTenant = await GetRootTenantAsync();
var before = await GetClaimsAsync(rootTenant, "Admin");
var before = await GetClaimsAsync(rootTenant, "Admin", ClaimConstants.Permission);

// Running the syncer twice in a row must not duplicate claims or throw.
using (var scope = _factory.Services.CreateScope())
Expand All @@ -80,10 +86,69 @@ public async Task SyncAsync_Should_Be_Idempotent_When_Claims_Already_Exist()
await syncer.SyncAsync(CancellationToken.None);
}

var after = await GetClaimsAsync(rootTenant, "Admin");
var after = await GetClaimsAsync(rootTenant, "Admin", ClaimConstants.Permission);
after.Count.ShouldBe(before.Count, "Syncer must not duplicate existing permission claims.");
}

[Fact]
public async Task SyncAsync_Should_Remove_Permission_Claims_The_Catalog_No_Longer_Grants()
{
var rootTenant = await GetRootTenantAsync();

// An Admin-only permission left on Basic is what narrowing IsBasic leaves behind on existing tenants.
const string narrowedPermission = IdentityPermissions.Roles.Delete;
PermissionConstants.Basic.ShouldNotContain(p => p.Name == narrowedPermission);
PermissionConstants.Admin.ShouldContain(p => p.Name == narrowedPermission);

await SeedClaimAsync(rootTenant, "Basic", ClaimConstants.Permission, RetiredPermission);
await SeedClaimAsync(rootTenant, "Basic", ClaimConstants.Permission, narrowedPermission);
await SeedClaimAsync(rootTenant, "Basic", NonPermissionClaimType, "kept");
await SeedClaimAsync(rootTenant, "Admin", ClaimConstants.Permission, RetiredPermission);

try
{
await RunSyncerAsync(rootTenant);

var basic = await GetClaimsAsync(rootTenant, "Basic", ClaimConstants.Permission);
basic.ShouldBe(
PermissionConstants.Basic.Select(p => p.Name).ToHashSet(StringComparer.Ordinal),
ignoreOrder: true,
"Basic must hold exactly the catalog's IsBasic permissions after the sync.");

var admin = await GetClaimsAsync(rootTenant, "Admin", ClaimConstants.Permission);
admin.ShouldNotContain(RetiredPermission);
admin.ShouldContain(narrowedPermission);

var nonPermission = await GetClaimsAsync(rootTenant, "Basic", NonPermissionClaimType);
nonPermission.ShouldContain("kept", "The sync must only reconcile permission claims.");
}
finally
{
await WipeClaimsAsync(rootTenant, "Basic", ClaimConstants.Permission, [RetiredPermission, narrowedPermission]);
await WipeClaimsAsync(rootTenant, "Basic", NonPermissionClaimType, ["kept"]);
await WipeClaimsAsync(rootTenant, "Admin", ClaimConstants.Permission, [RetiredPermission]);
}
}

[Fact]
public async Task SyncAsync_Should_Keep_Root_Permissions_On_Root_Tenant_Admin()
{
var rootTenant = await GetRootTenantAsync();
PermissionConstants.Root.ShouldNotBeEmpty();

// Checked after two passes: a sync that adds and removes against different targets flips the claims on each run.
for (int pass = 1; pass <= 2; pass++)
{
await RunSyncerAsync(rootTenant);

var admin = await GetClaimsAsync(rootTenant, "Admin", ClaimConstants.Permission);
var missing = PermissionConstants.Root.Select(p => p.Name).Where(p => !admin.Contains(p)).ToList();
missing.ShouldBeEmpty(
$"Sync pass {pass} stripped {missing.Count} root permission(s) from the root tenant's Admin: " +
$"[{string.Join(", ", missing)}]");
}
}

// ─── helpers ─────────────────────────────────────────────────────

private async Task<AppTenantInfo> GetRootTenantAsync()
Expand All @@ -95,7 +160,31 @@ private async Task<AppTenantInfo> GetRootTenantAsync()
return tenant;
}

private async Task WipeClaimsAsync(AppTenantInfo tenant, string roleName, IReadOnlyCollection<string> claimValues)
private async Task RunSyncerAsync(AppTenantInfo tenant)
{
using var scope = _factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);

var syncer = scope.ServiceProvider.GetRequiredService<RolePermissionSyncer>();
await syncer.SyncAsync(CancellationToken.None);
}

private async Task SeedClaimAsync(AppTenantInfo tenant, string roleName, string claimType, string claimValue)
{
using var scope = _factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);

var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<FshRole>>();
var role = await roleManager.Roles.SingleAsync(r => r.Name == roleName);

var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
db.RoleClaims.Add(new FshRoleClaim { RoleId = role.Id, ClaimType = claimType, ClaimValue = claimValue });
await db.SaveChangesAsync();
}

private async Task WipeClaimsAsync(AppTenantInfo tenant, string roleName, string claimType, IReadOnlyCollection<string> claimValues)
{
using var scope = _factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
Expand All @@ -110,14 +199,14 @@ private async Task WipeClaimsAsync(AppTenantInfo tenant, string roleName, IReadO
// ExecuteDeleteAsync would be cleaner but the in-memory enumeration is
// negligible for a handful of claims and avoids parameter-list edge cases.
var toRemove = await db.RoleClaims
.Where(rc => rc.RoleId == role.Id && rc.ClaimType == ClaimConstants.Permission)
.Where(rc => rc.RoleId == role.Id && rc.ClaimType == claimType)
.ToListAsync();

db.RoleClaims.RemoveRange(toRemove.Where(rc => rc.ClaimValue is not null && values.Contains(rc.ClaimValue)));
await db.SaveChangesAsync();
}

private async Task<HashSet<string>> GetClaimsAsync(AppTenantInfo tenant, string roleName)
private async Task<HashSet<string>> GetClaimsAsync(AppTenantInfo tenant, string roleName, string claimType)
{
using var scope = _factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
Expand All @@ -128,7 +217,7 @@ private async Task<HashSet<string>> GetClaimsAsync(AppTenantInfo tenant, string

var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
var claims = await db.RoleClaims
.Where(rc => rc.RoleId == role.Id && rc.ClaimType == ClaimConstants.Permission)
.Where(rc => rc.RoleId == role.Id && rc.ClaimType == claimType)
.Select(rc => rc.ClaimValue!)
.ToListAsync();

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Shared.Constants;
using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Identity.Authorization;
using FSH.Modules.Identity.Contracts.Authorization;
using FSH.Modules.Identity.Data;
using FSH.Modules.Identity.Domain;
using Integration.Tests.Infrastructure;
using Integration.Tests.Infrastructure.Extensions;
Expand Down Expand Up @@ -170,6 +173,37 @@ public async Task AssignUserRoles_Should_EvictCachedPermissions_When_RoleRemoved

#endregion

#region Role permission sync

[Fact]
public async Task RolePermissionSync_Should_EvictCachedPermissions_When_It_RevokesAClaim()
{
// Arrange — a Basic user whose role carries a permission the catalog no longer registers.
const string retiredPermission = "Permissions.RetiredCacheProbe.View";
using var adminClient = await _auth.CreateRootAdminClientAsync();
var uniqueId = Guid.NewGuid().ToString("N")[..8];

var (email, password, userId) = await CreateActiveUserAsync($"syncuser-{uniqueId}");
await AssignRoleAsync(adminClient, userId, RoleConstants.Basic);
await SeedRootRoleClaimAsync(RoleConstants.Basic, retiredPermission);

using var userClient = await _auth.CreateAuthenticatedClientAsync(email, password);

var warmed = await GetOwnPermissionsAsync(userClient);
warmed.ShouldContain(retiredPermission,
"Pre-condition: the user must hold the retired permission via Basic before the sync.");

// Act — the startup sync revokes it from Basic and must drop the warmed entry with it.
await RunRootTenantSyncAsync();

// Assert
var afterSync = await GetOwnPermissionsAsync(userClient);
afterSync.ShouldNotContain(retiredPermission,
"Cache was NOT invalidated: a permission the sync revoked is still being served from the stale cache entry.");
}

#endregion

// ─── helpers ─────────────────────────────────────────────────────

private static async Task<RoleDto> CreateRoleAsync(HttpClient adminClient, string name)
Expand Down Expand Up @@ -257,4 +291,36 @@ private static async Task<List<string>> GetOwnPermissionsAsync(HttpClient userCl

return (email, password, user.Id);
}

// System roles reject permission edits through the API, so the claim goes in directly.
private async Task SeedRootRoleClaimAsync(string roleName, string permission)
{
using var scope = _factory.Services.CreateScope();

var tenant = await scope.ServiceProvider
.GetRequiredService<IMultiTenantStore<AppTenantInfo>>()
.GetAsync(TestConstants.RootTenantId);
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);

var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<FshRole>>();
var role = await roleManager.Roles.SingleAsync(r => r.Name == roleName);

var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
db.RoleClaims.Add(new FshRoleClaim { RoleId = role.Id, ClaimType = ClaimConstants.Permission, ClaimValue = permission });
await db.SaveChangesAsync();
}

private async Task RunRootTenantSyncAsync()
{
using var scope = _factory.Services.CreateScope();

var tenant = await scope.ServiceProvider
.GetRequiredService<IMultiTenantStore<AppTenantInfo>>()
.GetAsync(TestConstants.RootTenantId);
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);

await scope.ServiceProvider.GetRequiredService<RolePermissionSyncer>().SyncAsync(CancellationToken.None);
}
}
Loading