feat(auth): add Redis authorization caching

This commit is contained in:
2026-08-01 12:20:31 +08:00
parent 84c2b0b21d
commit 46abf4d62f
36 changed files with 21757 additions and 35 deletions

View File

@@ -1,6 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
@@ -10,6 +12,170 @@ namespace Tiku.IntegrationTests.Api;
public sealed class AuthSessionLifecycleTests
{
[Fact]
public async Task Active_cache_falls_back_to_postgres_when_redis_is_unavailable()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["ConnectionStrings:Redis"] = "localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false",
["Security:AuthorizationCache:Mode"] = "Active"
});
var seed = await SeedActiveMemberAsync(factory);
var tokens = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
using var scope = factory.CreateSystemScope("Validate PostgreSQL authorization fallback");
Assert.NotNull(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
[Fact]
public async Task Active_redis_cache_rejects_disabled_membership_on_the_next_validation()
{
var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
if (string.IsNullOrWhiteSpace(redis))
{
return;
}
var commandRecorder = new RecordingDbCommandInterceptor();
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["ConnectionStrings:Redis"] = redis,
["Security:AuthorizationCache:Mode"] = "Active"
}, dbCommandInterceptor: commandRecorder);
var seed = await SeedActiveMemberAsync(factory);
var tokens = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
using (var scope = factory.CreateSystemScope("Warm Redis access security state"))
{
Assert.NotNull(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
var cached = await scope.ServiceProvider.GetRequiredService<IAccessSecurityCache>()
.GetAsync(new AccessSecurityCacheLookup(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
Assert.True(cached?.Complete);
}
commandRecorder.Reset();
using (var scope = factory.CreateSystemScope("Validate hot Redis access security state"))
{
Assert.NotNull(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
Assert.Empty(commandRecorder.Snapshot());
using (var scope = factory.CreateSystemScope("Disable membership and invalidate Redis"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var membership = await dbContext.TenantMemberships.SingleAsync(item =>
item.TenantId == seed.TenantId && item.UserId == seed.UserId);
membership.Status = MembershipStatus.Disabled;
await dbContext.SaveChangesAsync();
await scope.ServiceProvider.GetRequiredService<IAuthorizationStateInvalidator>()
.InvalidateMembershipAsync(seed.TenantId, seed.UserId);
}
using (var scope = factory.CreateSystemScope("Validate disabled cached membership"))
{
Assert.Null(await scope.ServiceProvider.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
}
[Fact]
public async Task Authorization_version_bypasses_stale_local_permission_snapshot_after_revocation()
{
var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
if (string.IsNullOrWhiteSpace(redis))
{
return;
}
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["ConnectionStrings:Redis"] = redis,
["Security:AuthorizationCache:Mode"] = "Active"
});
var seed = await SeedActiveMemberAsync(factory);
var roleId = Guid.NewGuid();
const string permissionCode = "tenant:auth-cache:manage";
await factory.SeedAsync(new BackendPermission
{
Code = permissionCode,
Name = "Authorization cache test permission",
Area = BackendPermissionArea.Tenant,
PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(BackendPermissions.TenantSettingsManage)
});
using (var scope = factory.CreateSystemScope("Seed versioned tenant permission"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = seed.TenantId
});
await dbContext.SaveChangesAsync();
dbContext.TenantBackendRoles.Add(new TenantBackendRole
{
Id = roleId,
TenantId = seed.TenantId,
Code = "versioned-cache-role",
Name = "Versioned cache role"
});
dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission
{
TenantId = seed.TenantId,
RoleId = roleId,
PermissionCode = permissionCode
});
dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole
{
TenantId = seed.TenantId,
UserId = seed.UserId,
RoleId = roleId
});
await dbContext.SaveChangesAsync();
}
var tokens = await IssueAsync(factory, seed);
Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator));
AuthSessionValidationResult validated;
using (var scope = factory.CreateSystemScope("Warm session state for permission snapshot"))
{
validated = Assert.IsType<AuthSessionValidationResult>(await scope.ServiceProvider
.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
Assert.True((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode));
using (var scope = factory.CreateSystemScope("Revoke permission and publish durable version"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var binding = await dbContext.TenantBackendRolePermissions.SingleAsync(item =>
item.TenantId == seed.TenantId && item.RoleId == roleId && item.PermissionCode == permissionCode);
dbContext.Remove(binding);
await dbContext.SaveChangesAsync();
await scope.ServiceProvider.GetRequiredService<IAuthorizationCacheInvalidationProcessor>()
.ProcessPendingAsync();
}
using (var scope = factory.CreateSystemScope("Reload session authorization version"))
{
validated = Assert.IsType<AuthSessionValidationResult>(await scope.ServiceProvider
.GetRequiredService<IAuthSessionStore>()
.ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId));
}
Assert.False((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode));
}
private static async Task<CurrentAccessSnapshot> LoadAccessAsync(
ApiTestFactory factory, SessionSeed seed, AuthSessionValidationResult validated)
{
using var scope = factory.Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.Initialize(seed.TenantId, null, TenantResolutionSource.Jwt);
((CurrentUser)scope.ServiceProvider.GetRequiredService<ICurrentUser>()).Load(new ClaimsPrincipal(
new ClaimsIdentity([new Claim(TikuClaimTypes.UserId, seed.UserId.ToString())], "integration")));
scope.ServiceProvider.GetRequiredService<IRequestSecurityState>().SetValidatedSession(validated);
return await scope.ServiceProvider.GetRequiredService<ICurrentAccessContext>().GetAsync();
}
[Fact]
public async Task Refresh_rotation_creates_a_child_and_replay_revokes_the_entire_family()
{