feat(auth): add Redis authorization caching
This commit is contained in:
@@ -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()
|
||||
{
|
||||
|
||||
47
Tiku.IntegrationTests/AuthorizationCachePersistenceTests.cs
Normal file
47
Tiku.IntegrationTests/AuthorizationCachePersistenceTests.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.IntegrationTests.Api;
|
||||
|
||||
namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class AuthorizationCachePersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Rbac_mutation_bumps_durable_scope_version_and_enqueues_invalidation_in_same_save()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
using var scope = factory.CreateSystemScope("Verify authorization version trigger");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.Tenants.Add(new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = tenantId.ToString("N"),
|
||||
Name = "Authorization cache trigger tenant"
|
||||
});
|
||||
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
|
||||
{
|
||||
Realm = AuthRealm.Tenant,
|
||||
TenantId = tenantId
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
dbContext.TenantBackendRoles.Add(new TenantBackendRole
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Code = "cache-trigger-role",
|
||||
Name = "Cache trigger role"
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
dbContext.ChangeTracker.Clear();
|
||||
|
||||
var version = await dbContext.AuthorizationScopeVersions.AsNoTracking()
|
||||
.SingleAsync(item => item.TenantId == tenantId);
|
||||
Assert.Equal(2, version.Version);
|
||||
Assert.True(await dbContext.AuthorizationCacheInvalidations.AsNoTracking().AnyAsync(item =>
|
||||
item.TargetType == "scope" && item.TenantId == tenantId && item.Version == version.Version));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ using System.Text;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Tenancy;
|
||||
|
||||
namespace Tiku.IntegrationTests;
|
||||
|
||||
@@ -71,10 +73,43 @@ public sealed class RedisSecurityStoreTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authorization_state_is_shared_and_older_version_cannot_overwrite_newer_version()
|
||||
{
|
||||
var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
|
||||
if (string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var environment = $"integration-authz-{Guid.NewGuid():N}";
|
||||
await using var first = BuildProvider(connectionString, environment);
|
||||
await using var second = BuildProvider(connectionString, environment);
|
||||
var writer = first.GetRequiredService<IAccessSecurityCache>();
|
||||
var reader = second.GetRequiredService<IAccessSecurityCache>();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var lookup = new AccessSecurityCacheLookup(sessionId, userId, AuthRealm.Tenant, tenantId);
|
||||
await writer.SetAsync(new AccessSecurityCacheState(
|
||||
new CachedSessionSecurityState(sessionId, userId, AuthRealm.Tenant, tenantId, "stamp", DateTimeOffset.UtcNow.AddMinutes(15), false),
|
||||
new CachedUserSecurityState(userId, UserStatus.Active, "stamp"),
|
||||
new CachedTenantSecurityState(tenantId, TenantStatus.Active),
|
||||
new CachedMembershipSecurityState(tenantId, userId, MembershipStatus.Active),
|
||||
null,
|
||||
new CachedAuthorizationVersion(AuthRealm.Tenant, tenantId, 1)));
|
||||
|
||||
Assert.True((await reader.GetAsync(lookup))?.Complete);
|
||||
await writer.SetAuthorizationVersionAsync(AuthRealm.Tenant, tenantId, 5);
|
||||
await writer.SetAuthorizationVersionAsync(AuthRealm.Tenant, tenantId, 4);
|
||||
Assert.Equal(5, (await reader.GetAsync(lookup))?.AuthorizationVersion?.Version);
|
||||
}
|
||||
|
||||
private static ServiceProvider BuildProvider(string connectionString, string environment)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.Configure<AuthorizationCacheOptions>(_ => { });
|
||||
services.AddRedisSecurity(connectionString, environment);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user