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

@@ -9,15 +9,22 @@ using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
using Tiku.Infrastructure.Security;
namespace Tiku.Infrastructure.Auth;
public sealed class AuthSessionStore(
TikuDbContext dbContext,
ITokenService tokenService,
IOptions<JwtOptions> options) : IAuthSessionStore
IOptions<JwtOptions> options,
IAccessSecurityCache? configuredAccessSecurityCache = null,
IOptions<AuthorizationCacheOptions>? configuredCacheOptions = null,
IAuthorizationStateInvalidator? configuredStateInvalidator = null) : IAuthSessionStore
{
private readonly JwtOptions options = options.Value;
private readonly IAccessSecurityCache accessSecurityCache = configuredAccessSecurityCache ?? new NullAuthorizationCache();
private readonly AuthorizationCacheOptions cacheOptions = configuredCacheOptions?.Value ?? new AuthorizationCacheOptions();
private readonly IAuthorizationStateInvalidator stateInvalidator = configuredStateInvalidator ?? new NullAuthorizationStateInvalidator();
public string GenerateRefreshToken(AuthRealm realm, Guid? tenantId, Guid sessionId)
{
@@ -80,6 +87,7 @@ public sealed class AuthSessionStore(
var tokenHash = HashRefreshToken(refreshToken);
var now = DateTimeOffset.UtcNow;
AuthorizationCacheTelemetry.PostgresFallback();
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
var current = await dbContext.AuthSessions.SingleOrDefaultAsync(
item => item.Id == locator.SessionId && item.Realm == locator.Realm &&
@@ -141,6 +149,7 @@ public sealed class AuthSessionStore(
dbContext.AuthSessions.Add(next);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await stateInvalidator.InvalidateSessionAsync(current.Id, cancellationToken);
return CreatePair(request, next, nextToken);
}
@@ -151,29 +160,63 @@ public sealed class AuthSessionStore(
Guid? tenantId,
CancellationToken cancellationToken = default)
{
var lookup = new AccessSecurityCacheLookup(sessionId, userId, realm, tenantId);
AccessSecurityCacheState? shadowState = null;
if (cacheOptions.Mode == AuthorizationCacheMode.Active && accessSecurityCache.IsConfigured)
{
try
{
var cached = await accessSecurityCache.GetAsync(lookup, cancellationToken);
if (cached is not null)
{
var platformVersionStale = realm == AuthRealm.Platform &&
cached.PlatformAccess!.AuthorizationVersion != cached.AuthorizationVersion!.Version;
if (!platformVersionStale)
{
return ValidateCached(cached, lookup);
}
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Redis is an acceleration layer; PostgreSQL remains authoritative.
}
}
else if (cacheOptions.Mode == AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
{
try
{
shadowState = await accessSecurityCache.GetAsync(lookup, cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Shadow failures never affect the PostgreSQL-authoritative decision.
}
}
var now = DateTimeOffset.UtcNow;
var state = await (
SessionValidationState? state;
try
{
state = await (
from session in dbContext.AuthSessions.AsNoTracking()
join user in dbContext.Users.AsNoTracking() on session.UserId equals user.Id
where session.Id == sessionId &&
session.UserId == userId &&
session.Realm == realm &&
session.TenantId == tenantId &&
session.RevokedAt == null &&
session.ExpiresAt > now
select new
{
UserStatus = user.Status,
UserSecurityStamp = user.SecurityStamp,
SessionSecurityStamp = session.SecurityStamp,
TenantAllowed = realm != AuthRealm.Tenant ||
session.TenantId == tenantId
select new SessionValidationState(
user.Status,
user.SecurityStamp!,
session.SecurityStamp,
realm != AuthRealm.Tenant ||
(tenantId != null &&
dbContext.Tenants.Any(item => item.Id == tenantId && item.Status == TenantStatus.Active) &&
dbContext.TenantMemberships.Any(item =>
item.TenantId == tenantId &&
item.UserId == userId &&
item.Status == MembershipStatus.Active)),
PlatformAllowed = realm != AuthRealm.Platform ||
realm != AuthRealm.Platform ||
(from userRole in dbContext.PlatformBackendUserRoles
join role in dbContext.PlatformBackendRoles on userRole.RoleId equals role.Id
join binding in dbContext.PlatformBackendRolePermissions on role.Id equals binding.RoleId
@@ -181,21 +224,130 @@ public sealed class AuthSessionStore(
where userRole.UserId == userId &&
role.Status == BackendRoleStatus.Active &&
(permission.Area == BackendPermissionArea.Platform || permission.Area == BackendPermissionArea.Both)
select permission.Id).Any()
})
select permission.Id).Any(),
realm == AuthRealm.Tenant && tenantId != null
? dbContext.Tenants.Where(item => item.Id == tenantId).Select(item => (TenantStatus?)item.Status).FirstOrDefault()
: null,
realm == AuthRealm.Tenant && tenantId != null
? dbContext.TenantMemberships.Where(item => item.TenantId == tenantId && item.UserId == userId)
.Select(item => (MembershipStatus?)item.Status).FirstOrDefault()
: null,
dbContext.AuthorizationScopeVersions
.Where(item => item.Realm == realm && item.TenantId == tenantId)
.Select(item => (long?)item.Version).FirstOrDefault() ?? 1L,
session.ExpiresAt,
session.RevokedAt != null))
.SingleOrDefaultAsync(cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw new AuthorizationSecurityUnavailableException(exception);
}
if (state is null ||
state.SessionRevoked ||
state.SessionExpiresAt <= now ||
state.UserStatus != UserStatus.Active ||
!string.Equals(state.UserSecurityStamp, state.SessionSecurityStamp, StringComparison.Ordinal) ||
!state.TenantAllowed ||
!state.PlatformAllowed)
{
if (state is not null &&
cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow &&
accessSecurityCache.IsConfigured)
{
try
{
await accessSecurityCache.SetAsync(ToCacheState(
state, sessionId, userId, realm, tenantId), cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// A negative cache write failure does not change the denial decision.
}
}
if (shadowState is not null)
{
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) is null);
}
return null;
}
return new AuthSessionValidationResult(userId, realm, tenantId);
var result = new AuthSessionValidationResult(userId, realm, tenantId, state.AuthorizationVersion);
if (shadowState is not null)
{
AuthorizationCacheTelemetry.ShadowCompared(ValidateCached(shadowState, lookup) == result);
}
if (cacheOptions.Mode is AuthorizationCacheMode.Active or AuthorizationCacheMode.Shadow && accessSecurityCache.IsConfigured)
{
try
{
await accessSecurityCache.SetAsync(
ToCacheState(state, sessionId, userId, realm, tenantId), cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// The database result is authoritative and remains usable.
}
}
return result;
}
private static AuthSessionValidationResult? ValidateCached(
AccessSecurityCacheState state, AccessSecurityCacheLookup lookup)
{
var session = state.Session!;
var user = state.User!;
var version = state.AuthorizationVersion!;
if (session.SessionId != lookup.SessionId || session.UserId != lookup.UserId ||
session.Realm != lookup.Realm || session.TenantId != lookup.TenantId ||
session.Revoked || session.ExpiresAt <= DateTimeOffset.UtcNow ||
user.UserId != lookup.UserId || user.Status != UserStatus.Active ||
!string.Equals(user.SecurityStamp, session.SecurityStamp, StringComparison.Ordinal) ||
version.Realm != lookup.Realm || version.TenantId != lookup.TenantId)
{
return null;
}
if (lookup.Realm == AuthRealm.Tenant &&
(state.Tenant!.Status != TenantStatus.Active || state.Membership!.Status != MembershipStatus.Active))
{
return null;
}
if (lookup.Realm == AuthRealm.Platform &&
(!state.PlatformAccess!.Allowed || state.PlatformAccess.AuthorizationVersion != version.Version))
{
return null;
}
return new AuthSessionValidationResult(lookup.UserId, lookup.Realm, lookup.TenantId, version.Version);
}
private static AccessSecurityCacheState ToCacheState(
SessionValidationState state, Guid sessionId, Guid userId, AuthRealm realm, Guid? tenantId) => new(
new CachedSessionSecurityState(sessionId, userId, realm, tenantId,
state.SessionSecurityStamp, state.SessionExpiresAt, state.SessionRevoked),
new CachedUserSecurityState(userId, state.UserStatus, state.UserSecurityStamp),
realm == AuthRealm.Tenant && state.TenantStatus.HasValue
? new CachedTenantSecurityState(tenantId!.Value, state.TenantStatus.Value)
: null,
realm == AuthRealm.Tenant && state.MembershipStatus.HasValue
? new CachedMembershipSecurityState(tenantId!.Value, userId, state.MembershipStatus.Value)
: null,
realm == AuthRealm.Platform
? new CachedPlatformAccessState(userId, state.AuthorizationVersion, state.PlatformAllowed)
: null,
new CachedAuthorizationVersion(realm, tenantId, state.AuthorizationVersion));
private sealed record SessionValidationState(
UserStatus UserStatus,
string UserSecurityStamp,
string SessionSecurityStamp,
bool TenantAllowed,
bool PlatformAllowed,
TenantStatus? TenantStatus,
MembershipStatus? MembershipStatus,
long AuthorizationVersion,
DateTimeOffset SessionExpiresAt,
bool SessionRevoked);
public async Task<AuthSessionValidationResult?> ResolveActiveSessionAsync(
Guid sessionId,
Guid userId,
@@ -237,6 +389,9 @@ public sealed class AuthSessionStore(
public async Task RevokeAllAsync(Guid userId, string reason, CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.UserId == userId && item.RevokedAt == null)
.Select(item => item.Id).ToArrayAsync(cancellationToken);
var count = await dbContext.AuthSessions.Where(item => item.UserId == userId && item.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.RevokedAt, DateTimeOffset.UtcNow)
@@ -252,6 +407,11 @@ public sealed class AuthSessionStore(
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var sessionId in sessionIds)
{
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
}
await stateInvalidator.InvalidateUserAsync(userId, cancellationToken);
}
}
@@ -264,6 +424,9 @@ public sealed class AuthSessionStore(
{
ValidateRealm(realm, tenantId);
var now = DateTimeOffset.UtcNow;
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
.Select(item => item.Id).ToArrayAsync(cancellationToken);
var count = await dbContext.AuthSessions
.Where(item => item.UserId == userId && item.Realm == realm && item.TenantId == tenantId && item.RevokedAt == null)
.ExecuteUpdateAsync(setters => setters
@@ -281,6 +444,10 @@ public sealed class AuthSessionStore(
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { realm, reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var sessionId in sessionIds)
{
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
}
}
}
@@ -400,6 +567,9 @@ public sealed class AuthSessionStore(
private async Task<int> RevokeFamilyCoreAsync(Guid familyId, string reason, DateTimeOffset now, CancellationToken cancellationToken)
{
var sessionIds = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.TokenFamilyId == familyId && item.RevokedAt == null)
.Select(item => item.Id).ToArrayAsync(cancellationToken);
var owner = await dbContext.AuthSessions.AsNoTracking()
.Where(item => item.TokenFamilyId == familyId)
.Select(item => new { item.UserId, item.TenantId })
@@ -420,6 +590,10 @@ public sealed class AuthSessionStore(
Details = System.Text.Json.JsonSerializer.SerializeToElement(new { reason, count, revokedAt = now })
});
await dbContext.SaveChangesAsync(cancellationToken);
foreach (var sessionId in sessionIds)
{
await stateInvalidator.InvalidateSessionAsync(sessionId, cancellationToken);
}
}
return count;

View File

@@ -13,7 +13,8 @@ namespace Tiku.Infrastructure.Backoffice;
internal sealed class BackofficeService(
TikuDbContext dbContext,
IOperationAuditService auditService,
IFeatureAccessService featureAccessService) : IBackofficeService
IFeatureAccessService featureAccessService,
IAuthorizationStateInvalidator authorizationStateInvalidator) : IBackofficeService
{
public async Task<BackofficeUiBootstrap> GetTenantUiBootstrapAsync(
CurrentAccessSnapshot access,
@@ -133,6 +134,7 @@ internal sealed class BackofficeService(
role.Description = command.Description?.Trim();
role.DataScope = command.DataScope ?? JsonDefaults.Object();
await dbContext.SaveChangesAsync(cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
await AuditAsync(actor, "tenant.role.upserted", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
}
@@ -160,6 +162,7 @@ internal sealed class BackofficeService(
role.Status = command.Status;
role.Description = command.Description?.Trim();
await dbContext.SaveChangesAsync(cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
await AuditAsync(actor, "platform.role.upserted", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
}
@@ -179,6 +182,7 @@ internal sealed class BackofficeService(
}
await ReplaceTenantBindingsCoreAsync(tenantId, role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
await AuditAsync(actor, "tenant.role.bindings_replaced", "tenant_backend_roles", role.Id, new { role.Code }, cancellationToken);
return await LoadTenantRoleAsync(tenantId, role.Id, cancellationToken);
}
@@ -198,6 +202,7 @@ internal sealed class BackofficeService(
}
await ReplacePlatformBindingsCoreAsync(role.Id, command.PermissionCodes, command.MenuCodes, cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
await AuditAsync(actor, "platform.role.bindings_replaced", "platform_backend_roles", role.Id, new { role.Code }, cancellationToken);
return await LoadPlatformRoleAsync(role.Id, cancellationToken);
}
@@ -242,6 +247,7 @@ internal sealed class BackofficeService(
RoleId = roleId
}));
await dbContext.SaveChangesAsync(cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
await AuditAsync(actor, "tenant.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
}
@@ -269,6 +275,7 @@ internal sealed class BackofficeService(
RoleId = roleId
}));
await dbContext.SaveChangesAsync(cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
await AuditAsync(actor, "platform.user_roles.replaced", "users", command.UserId, new { roleIds }, cancellationToken);
}

View File

@@ -132,7 +132,7 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
.SingleOrDefaultAsync(item => item.Mode == TenantMode.PlatformOwned, cancellationToken);
if (platformOwnedTenant is null)
{
dbContext.Tenants.Add(new Tenant
var tenant = new Tenant
{
Slug = "platform-public-content",
Name = "平台公共内容",
@@ -140,6 +140,12 @@ public sealed class BuiltinBackofficeCatalogSeeder(TikuDbContext dbContext)
Mode = TenantMode.PlatformOwned,
BillingStatus = BillingStatus.Active,
Metadata = JsonDefaults.Object()
};
dbContext.Tenants.Add(tenant);
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = tenant.Id
});
}

View File

@@ -266,6 +266,11 @@ public static class DevelopmentPlatformAdminSeeder
Metadata = JsonSerializer.SerializeToElement(new { demo = true })
};
dbContext.Tenants.Add(tenant);
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = tenant.Id
});
return tenant;
}

View File

@@ -91,6 +91,9 @@ public static class DependencyInjection
services.Configure<PasswordHasherOptions>(options => options.IterationCount = 210_000);
services.AddScoped<ITenantDirectory, TenantDirectory>();
services.AddSingleton<IRedisSecurityStore, NullRedisSecurityStore>();
services.AddSingleton<NullAuthorizationCache>();
services.AddSingleton<IAccessSecurityCache>(provider => provider.GetRequiredService<NullAuthorizationCache>());
services.AddSingleton<IAuthorizationSnapshotCache>(provider => provider.GetRequiredService<NullAuthorizationCache>());
services.AddMemoryCache();
services.AddScoped<ITenantFrontendConfigService, TenantFrontendConfigService>();
services.AddScoped<ITenantExternalProviderConfigService, TenantExternalProviderConfigService>();
@@ -105,6 +108,7 @@ public static class DependencyInjection
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<AuthSessionStore>();
services.AddScoped<IAuthSessionStore>(provider => provider.GetRequiredService<AuthSessionStore>());
services.AddScoped<IRequestAccessValidator, RequestAccessValidator>();
services.AddScoped<ISmsProvider, AliyunSmsProvider>();
services.AddScoped<ISmsVerificationService, SmsVerificationService>();
services.AddScoped<IWechatOAuthClient, WechatOAuthClient>();
@@ -149,6 +153,8 @@ public static class DependencyInjection
services.AddScoped<ITenantOnboardingService, TenantOnboardingService>();
services.AddScoped<ITenantLifecycleService, TenantLifecycleService>();
services.AddScoped<ICurrentAccessContext, CurrentAccessContext>();
services.AddScoped<IAuthorizationStateInvalidator, AuthorizationStateInvalidator>();
services.AddScoped<IAuthorizationCacheInvalidationProcessor, AuthorizationCacheInvalidationProcessor>();
services.AddScoped<ITenantFeatureSnapshotProvider, TenantFeatureSnapshotProvider>();
services.AddSingleton<TenantFeatureCacheInvalidator>();
services.AddSingleton<ITenantFeatureCacheInvalidator>(provider => provider.GetRequiredService<TenantFeatureCacheInvalidator>());
@@ -200,6 +206,12 @@ public static class DependencyInjection
environmentName,
provider.GetRequiredService<Microsoft.Extensions.Logging.ILogger<RedisSecurityStore>>()));
services.AddSingleton<IRedisSecurityStore>(provider => provider.GetRequiredService<RedisSecurityStore>());
services.AddSingleton(provider => new RedisAuthorizationCache(
provider.GetRequiredService<IConnectionMultiplexer>(),
provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<AuthorizationCacheOptions>>(),
environmentName));
services.AddSingleton<IAccessSecurityCache>(provider => provider.GetRequiredService<RedisAuthorizationCache>());
services.AddSingleton<IAuthorizationSnapshotCache>(provider => provider.GetRequiredService<RedisAuthorizationCache>());
services.AddStackExchangeRedisCache(cache => cache.ConfigurationOptions = options);
return services;
}

View File

@@ -264,6 +264,43 @@ internal sealed class PlatformBackendUserRoleConfiguration : IEntityTypeConfigur
}
}
internal sealed class AuthorizationScopeVersionConfiguration : IEntityTypeConfiguration<AuthorizationScopeVersion>
{
public void Configure(EntityTypeBuilder<AuthorizationScopeVersion> builder)
{
builder.ConfigureEntity("authorization_scope_versions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Realm).HasSnakeCaseEnum();
builder.Property(entity => entity.Version).HasDefaultValue(1L).IsConcurrencyToken();
builder.ToTable("authorization_scope_versions", table => table.HasCheckConstraint(
"ck_authorization_scope_versions_realm_tenant",
"(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)"));
builder.HasIndex(entity => entity.TenantId)
.IsUnique()
.HasFilter("tenant_id is not null");
builder.HasIndex(entity => entity.Realm)
.IsUnique()
.HasFilter("realm = 'platform'");
builder.HasOne<Tenant>().WithMany().HasForeignKey(entity => entity.TenantId).OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class AuthorizationCacheInvalidationConfiguration : IEntityTypeConfiguration<AuthorizationCacheInvalidation>
{
public void Configure(EntityTypeBuilder<AuthorizationCacheInvalidation> builder)
{
builder.ConfigureEntity("authorization_cache_invalidations");
builder.ConfigureTimestamps();
builder.Property(entity => entity.TargetType).HasMaxLength(40);
builder.Property(entity => entity.Realm).HasConversion(
value => value.HasValue ? value.Value.ToString().ToLowerInvariant() : null,
value => value == null ? null : Enum.Parse<AuthRealm>(value, true));
builder.Property(entity => entity.LastError).HasMaxLength(2000);
builder.HasIndex(entity => new { entity.ProcessedAt, entity.CreatedAt });
builder.HasIndex(entity => new { entity.TargetType, entity.TenantId, entity.UserId, entity.SessionId, entity.Version });
}
}
internal sealed class BackgroundJobConfiguration : IEntityTypeConfiguration<BackgroundJob>
{
public void Configure(EntityTypeBuilder<BackgroundJob> builder)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,201 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Tiku.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAuthorizationCaching : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "authorization_cache_invalidations",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
target_type = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
user_id = table.Column<Guid>(type: "uuid", nullable: true),
session_id = table.Column<Guid>(type: "uuid", nullable: true),
realm = table.Column<string>(type: "text", nullable: true),
version = table.Column<long>(type: "bigint", nullable: true),
processed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
attempt_count = table.Column<int>(type: "integer", nullable: false),
last_error = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_authorization_cache_invalidations", x => x.id);
});
migrationBuilder.CreateTable(
name: "authorization_scope_versions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
realm = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
tenant_id = table.Column<Guid>(type: "uuid", nullable: true),
version = table.Column<long>(type: "bigint", nullable: false, defaultValue: 1L),
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("pk_authorization_scope_versions", x => x.id);
table.CheckConstraint("ck_authorization_scope_versions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
table.ForeignKey(
name: "fk_authorization_scope_versions_tenants_tenant_id",
column: x => x.tenant_id,
principalTable: "tenants",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_authorization_cache_invalidations_processed_at_created_at",
table: "authorization_cache_invalidations",
columns: new[] { "processed_at", "created_at" });
migrationBuilder.CreateIndex(
name: "ix_authorization_cache_invalidations_target_type_tenant_id_use~",
table: "authorization_cache_invalidations",
columns: new[] { "target_type", "tenant_id", "user_id", "session_id", "version" });
migrationBuilder.CreateIndex(
name: "ix_authorization_scope_versions_realm",
table: "authorization_scope_versions",
column: "realm",
unique: true,
filter: "realm = 'platform'");
migrationBuilder.CreateIndex(
name: "ix_authorization_scope_versions_tenant_id",
table: "authorization_scope_versions",
column: "tenant_id",
unique: true,
filter: "tenant_id is not null");
migrationBuilder.Sql("""
INSERT INTO authorization_scope_versions
(id, realm, tenant_id, version, created_at, updated_at)
VALUES
(gen_random_uuid(), 'platform', NULL, 1, now(), now());
INSERT INTO authorization_scope_versions
(id, realm, tenant_id, version, created_at, updated_at)
SELECT gen_random_uuid(), 'tenant', id, 1, now(), now()
FROM tenants;
""");
migrationBuilder.Sql("""
CREATE OR REPLACE FUNCTION tiku_bump_tenant_authorization_version()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
scope_tenant_id uuid;
next_version bigint;
BEGIN
IF TG_OP = 'DELETE' THEN
scope_tenant_id := OLD.tenant_id;
ELSE
scope_tenant_id := NEW.tenant_id;
END IF;
UPDATE authorization_scope_versions
SET version = version + 1, updated_at = now()
WHERE realm = 'tenant' AND tenant_id = scope_tenant_id
RETURNING version INTO next_version;
IF next_version IS NOT NULL THEN
INSERT INTO authorization_cache_invalidations
(id, target_type, tenant_id, realm, version, attempt_count, created_at, updated_at)
VALUES
(gen_random_uuid(), 'scope', scope_tenant_id, 'tenant', next_version, 0, now(), now());
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION tiku_bump_platform_authorization_version()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE next_version bigint;
BEGIN
UPDATE authorization_scope_versions
SET version = version + 1, updated_at = now()
WHERE realm = 'platform' AND tenant_id IS NULL
RETURNING version INTO next_version;
INSERT INTO authorization_cache_invalidations
(id, target_type, realm, version, attempt_count, created_at, updated_at)
VALUES
(gen_random_uuid(), 'scope', 'platform', next_version, 0, now(), now());
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION tiku_bump_all_authorization_versions()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE authorization_scope_versions SET version = version + 1, updated_at = now();
INSERT INTO authorization_cache_invalidations
(id, target_type, tenant_id, realm, version, attempt_count, created_at, updated_at)
SELECT gen_random_uuid(), 'scope', tenant_id, realm, version, 0, now(), now()
FROM authorization_scope_versions;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_tenant_backend_roles_authorization_version
AFTER INSERT OR UPDATE OR DELETE ON tenant_backend_roles
FOR EACH ROW EXECUTE FUNCTION tiku_bump_tenant_authorization_version();
CREATE TRIGGER trg_tenant_backend_role_permissions_authorization_version
AFTER INSERT OR UPDATE OR DELETE ON tenant_backend_role_permissions
FOR EACH ROW EXECUTE FUNCTION tiku_bump_tenant_authorization_version();
CREATE TRIGGER trg_tenant_backend_user_roles_authorization_version
AFTER INSERT OR UPDATE OR DELETE ON tenant_backend_user_roles
FOR EACH ROW EXECUTE FUNCTION tiku_bump_tenant_authorization_version();
CREATE TRIGGER trg_platform_backend_roles_authorization_version
AFTER INSERT OR UPDATE OR DELETE ON platform_backend_roles
FOR EACH ROW EXECUTE FUNCTION tiku_bump_platform_authorization_version();
CREATE TRIGGER trg_platform_backend_role_permissions_authorization_version
AFTER INSERT OR UPDATE OR DELETE ON platform_backend_role_permissions
FOR EACH ROW EXECUTE FUNCTION tiku_bump_platform_authorization_version();
CREATE TRIGGER trg_platform_backend_user_roles_authorization_version
AFTER INSERT OR UPDATE OR DELETE ON platform_backend_user_roles
FOR EACH ROW EXECUTE FUNCTION tiku_bump_platform_authorization_version();
CREATE TRIGGER trg_backend_permissions_authorization_version
AFTER INSERT OR UPDATE OR DELETE ON backend_permissions
FOR EACH STATEMENT EXECUTE FUNCTION tiku_bump_all_authorization_versions();
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
DROP FUNCTION IF EXISTS tiku_bump_tenant_authorization_version() CASCADE;
DROP FUNCTION IF EXISTS tiku_bump_platform_authorization_version() CASCADE;
DROP FUNCTION IF EXISTS tiku_bump_all_authorization_versions() CASCADE;
""");
migrationBuilder.DropTable(
name: "authorization_cache_invalidations");
migrationBuilder.DropTable(
name: "authorization_scope_versions");
}
}
}

View File

@@ -10235,6 +10235,133 @@ namespace Tiku.Infrastructure.Persistence.Migrations
b.ToTable("audit_logs", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.AuthorizationCacheInvalidation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<int>("AttemptCount")
.HasColumnType("integer")
.HasColumnName("attempt_count");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("LastError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("last_error");
b.Property<DateTimeOffset?>("ProcessedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("processed_at");
b.Property<string>("Realm")
.HasColumnType("text")
.HasColumnName("realm");
b.Property<Guid?>("SessionId")
.HasColumnType("uuid")
.HasColumnName("session_id");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)")
.HasColumnName("target_type");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<Guid?>("UserId")
.HasColumnType("uuid")
.HasColumnName("user_id");
b.Property<long?>("Version")
.HasColumnType("bigint")
.HasColumnName("version");
b.HasKey("Id")
.HasName("pk_authorization_cache_invalidations");
b.HasIndex("ProcessedAt", "CreatedAt")
.HasDatabaseName("ix_authorization_cache_invalidations_processed_at_created_at");
b.HasIndex("TargetType", "TenantId", "UserId", "SessionId", "Version")
.HasDatabaseName("ix_authorization_cache_invalidations_target_type_tenant_id_use~");
b.ToTable("authorization_cache_invalidations", (string)null);
});
modelBuilder.Entity("Tiku.Domain.Operations.AuthorizationScopeVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Realm")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("realm");
b.Property<Guid?>("TenantId")
.HasColumnType("uuid")
.HasColumnName("tenant_id");
b.Property<DateTimeOffset>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.Property<long>("Version")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasDefaultValue(1L)
.HasColumnName("version");
b.HasKey("Id")
.HasName("pk_authorization_scope_versions");
b.HasIndex("Realm")
.IsUnique()
.HasDatabaseName("ix_authorization_scope_versions_realm")
.HasFilter("realm = 'platform'");
b.HasIndex("TenantId")
.IsUnique()
.HasDatabaseName("ix_authorization_scope_versions_tenant_id")
.HasFilter("tenant_id is not null");
b.ToTable("authorization_scope_versions", null, t =>
{
t.HasCheckConstraint("ck_authorization_scope_versions_realm_tenant", "(realm = 'tenant' and tenant_id is not null) or (realm = 'platform' and tenant_id is null)");
});
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
{
b.Property<Guid>("Id")
@@ -18825,6 +18952,15 @@ namespace Tiku.Infrastructure.Persistence.Migrations
.HasConstraintName("fk_audit_logs_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.AuthorizationScopeVersion", b =>
{
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
.WithMany()
.HasForeignKey("TenantId")
.OnDelete(DeleteBehavior.Cascade)
.HasConstraintName("fk_authorization_scope_versions_tenants_tenant_id");
});
modelBuilder.Entity("Tiku.Domain.Operations.BackendMenu", b =>
{
b.HasOne("Tiku.Domain.Operations.BackendPermission", null)

View File

@@ -173,6 +173,8 @@ public sealed class TikuDbContext(
public DbSet<PlatformBackendRolePermission> PlatformBackendRolePermissions => Set<PlatformBackendRolePermission>();
public DbSet<PlatformBackendRoleMenu> PlatformBackendRoleMenus => Set<PlatformBackendRoleMenu>();
public DbSet<PlatformBackendUserRole> PlatformBackendUserRoles => Set<PlatformBackendUserRole>();
public DbSet<AuthorizationScopeVersion> AuthorizationScopeVersions => Set<AuthorizationScopeVersion>();
public DbSet<AuthorizationCacheInvalidation> AuthorizationCacheInvalidations => Set<AuthorizationCacheInvalidation>();
public DbSet<BackgroundJob> BackgroundJobs => Set<BackgroundJob>();
public DbSet<TenantLifecycleOperation> TenantLifecycleOperations => Set<TenantLifecycleOperation>();
public DbSet<WorkerHeartbeat> WorkerHeartbeats => Set<WorkerHeartbeat>();

View File

@@ -176,6 +176,11 @@ internal sealed class PlatformAdminService(
Metadata = JsonObjectOrDefault(command.Metadata)
};
dbContext.Tenants.Add(tenant);
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = AuthRealm.Tenant,
TenantId = tenant.Id
});
var owner = new User
{
Email = ownerEmail,
@@ -250,6 +255,8 @@ internal sealed class PlatformAdminService(
await dbContext.SaveChangesAsync(cancellationToken);
await provider.GetRequiredService<ITenantFeatureCacheInvalidator>()
.InvalidateAsync(tenant.Id, cancellationToken);
await provider.GetRequiredService<IAuthorizationStateInvalidator>()
.InvalidateTenantAsync(tenant.Id, cancellationToken);
var domainCount = await dbContext.TenantDomains.CountAsync(domain => domain.TenantId == tenant.Id, cancellationToken);
var expiresAt = await dbContext.TenantSaasSubscriptions
.Where(subscription => subscription.TenantId == tenant.Id)
@@ -379,7 +386,7 @@ internal sealed class PlatformAdminService(
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
return await ExecuteSystemAsync("platform staff upsert", async dbContext =>
return await ExecuteSystemAsync("platform staff upsert", async (provider, dbContext) =>
{
var user = command.UserId.HasValue
? await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId.Value, cancellationToken)
@@ -431,6 +438,9 @@ internal sealed class PlatformAdminService(
}));
AddAudit(dbContext, actor, "platform.staff.upserted", user.Id, new { user.Email, Phone = MaskPhone(user.Phone), user.Status, RoleIds = roleIds });
await dbContext.SaveChangesAsync(cancellationToken);
var invalidator = provider.GetRequiredService<IAuthorizationStateInvalidator>();
await invalidator.InvalidateUserAsync(user.Id, cancellationToken);
await invalidator.BumpScopeAsync(AuthRealm.Platform, null, cancellationToken);
var roleCodes = await dbContext.PlatformBackendRoles.AsNoTracking()
.Where(role => roleIds.Contains(role.Id))
.Select(role => role.Code)
@@ -445,7 +455,7 @@ internal sealed class PlatformAdminService(
CancellationToken cancellationToken = default)
{
await AssertPlatformPermissionAsync(actor, BackendPermissions.PlatformStaffManage, cancellationToken);
return await ExecuteSystemAsync("platform staff status update", async dbContext =>
return await ExecuteSystemAsync("platform staff status update", async (provider, dbContext) =>
{
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == command.UserId, cancellationToken)
?? throw new PlatformAdminException("Platform staff user was not found.", "staff_not_found");
@@ -453,6 +463,8 @@ internal sealed class PlatformAdminService(
user.Status = command.Status;
AddAudit(dbContext, actor, "platform.staff.status_changed", user.Id, new { From = from, To = command.Status, command.Reason });
await dbContext.SaveChangesAsync(cancellationToken);
await provider.GetRequiredService<IAuthorizationStateInvalidator>()
.InvalidateUserAsync(user.Id, cancellationToken);
var roleCodes = await (
from binding in dbContext.PlatformBackendUserRoles.AsNoTracking()
join role in dbContext.PlatformBackendRoles.AsNoTracking() on binding.RoleId equals role.Id

View File

@@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Security;
internal sealed class AuthorizationCacheInvalidationProcessor(
TikuDbContext dbContext,
IAccessSecurityCache cache) : IAuthorizationCacheInvalidationProcessor
{
public async Task<int> ProcessPendingAsync(int batchSize = 100, CancellationToken cancellationToken = default)
{
if (!cache.IsConfigured)
{
return 0;
}
var items = await dbContext.AuthorizationCacheInvalidations
.Where(item => item.ProcessedAt == null)
.OrderBy(item => item.CreatedAt)
.Take(Math.Clamp(batchSize, 1, 500))
.ToArrayAsync(cancellationToken);
var processed = 0;
foreach (var item in items)
{
try
{
switch (item.TargetType)
{
case "session" when item.SessionId.HasValue:
await cache.InvalidateSessionAsync(item.SessionId.Value, cancellationToken);
break;
case "user" when item.UserId.HasValue:
await cache.InvalidateUserAsync(item.UserId.Value, cancellationToken);
break;
case "tenant" when item.TenantId.HasValue:
await cache.InvalidateTenantAsync(item.TenantId.Value, cancellationToken);
break;
case "membership" when item.TenantId.HasValue && item.UserId.HasValue:
await cache.InvalidateMembershipAsync(item.TenantId.Value, item.UserId.Value, cancellationToken);
break;
case "scope" when item.Realm.HasValue && item.Version.HasValue:
await cache.SetAuthorizationVersionAsync(item.Realm.Value, item.TenantId, item.Version.Value, cancellationToken);
break;
default:
throw new InvalidOperationException($"Invalid authorization cache invalidation {item.Id}.");
}
item.ProcessedAt = DateTimeOffset.UtcNow;
item.LastError = null;
item.AttemptCount++;
processed++;
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
item.AttemptCount++;
item.LastError = exception.Message;
}
}
await dbContext.SaveChangesAsync(cancellationToken);
return processed;
}
}

View File

@@ -0,0 +1,29 @@
using System.Diagnostics.Metrics;
namespace Tiku.Infrastructure.Security;
public static class AuthorizationCacheTelemetry
{
public const string MeterName = "Tiku.Security.AuthorizationCache";
private static readonly Meter Meter = new(MeterName, "1.0.0");
private static readonly Counter<long> ReadCounter = Meter.CreateCounter<long>("tiku.authorization_cache.reads");
private static readonly Counter<long> FallbackCounter = Meter.CreateCounter<long>("tiku.authorization_cache.postgres_fallbacks");
private static readonly Counter<long> InvalidationCounter = Meter.CreateCounter<long>("tiku.authorization_cache.invalidations");
private static readonly Counter<long> VersionMismatchCounter = Meter.CreateCounter<long>("tiku.authorization_cache.version_mismatches");
private static readonly Counter<long> ShadowMismatchCounter = Meter.CreateCounter<long>("tiku.authorization_cache.shadow_mismatches");
private static readonly Histogram<double> RedisDuration = Meter.CreateHistogram<double>("tiku.authorization_cache.redis.duration", "ms");
public static void Read(string source, bool hit) => ReadCounter.Add(1,
new KeyValuePair<string, object?>("source", source),
new KeyValuePair<string, object?>("hit", hit));
public static void PostgresFallback() => FallbackCounter.Add(1);
public static void Invalidated(string target, bool succeeded) => InvalidationCounter.Add(1,
new KeyValuePair<string, object?>("target", target),
new KeyValuePair<string, object?>("succeeded", succeeded));
public static void VersionMismatch() => VersionMismatchCounter.Add(1);
public static void ShadowCompared(bool match)
{
if (!match) ShadowMismatchCounter.Add(1);
}
public static void RecordRedisDuration(double milliseconds) => RedisDuration.Record(milliseconds);
}

View File

@@ -0,0 +1,105 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Security;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure.Security;
internal sealed class AuthorizationStateInvalidator(
TikuDbContext dbContext,
IAccessSecurityCache cache) : IAuthorizationStateInvalidator
{
public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
ExecuteAsync("session", null, null, sessionId, null, null,
token => cache.InvalidateSessionAsync(sessionId, token), cancellationToken);
public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) =>
ExecuteAsync("user", null, userId, null, null, null,
token => cache.InvalidateUserAsync(userId, token), cancellationToken);
public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
ExecuteAsync("tenant", tenantId, null, null, AuthRealm.Tenant, null,
token => cache.InvalidateTenantAsync(tenantId, token), cancellationToken);
public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) =>
ExecuteAsync("membership", tenantId, userId, null, AuthRealm.Tenant, null,
token => cache.InvalidateMembershipAsync(tenantId, userId, token), cancellationToken);
public async Task<long> BumpScopeAsync(AuthRealm realm, Guid? tenantId, CancellationToken cancellationToken = default)
{
var updated = await dbContext.AuthorizationScopeVersions
.Where(item => item.Realm == realm && item.TenantId == tenantId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.Version, item => item.Version + 1)
.SetProperty(item => item.UpdatedAt, DateTimeOffset.UtcNow), cancellationToken);
if (updated == 0)
{
dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion
{
Realm = realm,
TenantId = tenantId,
Version = 2
});
await dbContext.SaveChangesAsync(cancellationToken);
}
var version = await dbContext.AuthorizationScopeVersions.AsNoTracking()
.Where(item => item.Realm == realm && item.TenantId == tenantId)
.Select(item => item.Version)
.SingleAsync(cancellationToken);
await ExecuteAsync("scope", tenantId, null, null, realm, version,
token => cache.SetAuthorizationVersionAsync(realm, tenantId, version, token), cancellationToken);
return version;
}
private async Task ExecuteAsync(
string targetType, Guid? tenantId, Guid? userId, Guid? sessionId,
AuthRealm? realm, long? version, Func<CancellationToken, Task> operation,
CancellationToken cancellationToken)
{
if (!cache.IsConfigured)
{
return;
}
var invalidation = new AuthorizationCacheInvalidation
{
TargetType = targetType,
TenantId = tenantId,
UserId = userId,
SessionId = sessionId,
Realm = realm,
Version = version
};
dbContext.AuthorizationCacheInvalidations.Add(invalidation);
await dbContext.SaveChangesAsync(cancellationToken);
try
{
await operation(cancellationToken);
invalidation.ProcessedAt = DateTimeOffset.UtcNow;
invalidation.AttemptCount++;
await dbContext.SaveChangesAsync(cancellationToken);
AuthorizationCacheTelemetry.Invalidated(targetType, true);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
invalidation.AttemptCount++;
invalidation.LastError = exception.Message;
await dbContext.SaveChangesAsync(CancellationToken.None);
AuthorizationCacheTelemetry.Invalidated(targetType, false);
if (dbContext.Database.CurrentTransaction is not null)
{
return;
}
throw new AuthorizationSecurityUnavailableException(exception);
}
}
}
internal sealed class NullAuthorizationStateInvalidator : IAuthorizationStateInvalidator
{
public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<long> BumpScopeAsync(AuthRealm realm, Guid? tenantId, CancellationToken cancellationToken = default) => Task.FromResult(1L);
}

View File

@@ -1,4 +1,7 @@
using Microsoft.EntityFrameworkCore;
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Tiku.Application.Security;
using Tiku.Application.Auth;
using Tiku.Domain.Identity;
@@ -12,8 +15,12 @@ internal sealed class CurrentAccessContext(
ICurrentUser currentUser,
ITenantContext tenantContext,
IRequestSecurityState requestSecurityState,
TikuDbContext dbContext) : ICurrentAccessContext
TikuDbContext dbContext,
IMemoryCache memoryCache,
IAuthorizationSnapshotCache snapshotCache,
IOptions<AuthorizationCacheOptions> cacheOptions) : ICurrentAccessContext
{
private static readonly ConcurrentDictionary<string, Lazy<Task<CurrentAccessSnapshot>>> SnapshotFlights = new();
private Task<CurrentAccessSnapshot>? snapshotTask;
public Task<CurrentAccessSnapshot> GetAsync(CancellationToken cancellationToken = default)
@@ -34,6 +41,15 @@ internal sealed class CurrentAccessContext(
var isValidated = validatedSession is not null &&
validatedSession.UserId == userId &&
validatedSession.TenantId == tenantContext.TenantId;
if (isValidated && cacheOptions.Value.Mode == AuthorizationCacheMode.Active)
{
return await LoadCachedSnapshotAsync(
userId,
tenantContext.TenantId,
validatedSession!.Realm,
validatedSession.AuthorizationVersion,
cancellationToken);
}
if (!isValidated)
{
var isUserActive = await dbContext.Users.AsNoTracking()
@@ -138,6 +154,94 @@ internal sealed class CurrentAccessContext(
.ToHashSet(StringComparer.Ordinal);
}
private async Task<CurrentAccessSnapshot> LoadCachedSnapshotAsync(
Guid userId, Guid? tenantId, AuthRealm realm, long version, CancellationToken cancellationToken)
{
var localKey = $"authorization-snapshot:v1:{realm}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}:{version}";
if (memoryCache.TryGetValue<CurrentAccessSnapshot>(localKey, out var local) && local is not null)
{
AuthorizationCacheTelemetry.Read("l1_snapshot", true);
return local;
}
AuthorizationCacheTelemetry.Read("l1_snapshot", false);
try
{
var distributed = await snapshotCache.GetAsync(realm, tenantId, userId, cancellationToken);
if (distributed is not null && distributed.Version == version)
{
AuthorizationCacheTelemetry.Read("redis_snapshot", true);
memoryCache.Set(localKey, distributed.Snapshot,
TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds));
return distributed.Snapshot;
}
if (distributed is not null)
{
AuthorizationCacheTelemetry.VersionMismatch();
}
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// A confirmed session may safely fall back to the authorization source of truth.
}
AuthorizationCacheTelemetry.Read("redis_snapshot", false);
AuthorizationCacheTelemetry.PostgresFallback();
var flight = SnapshotFlights.GetOrAdd(localKey, _ => new Lazy<Task<CurrentAccessSnapshot>>(
() => LoadPermissionSnapshotAsync(userId, tenantId, CancellationToken.None),
LazyThreadSafetyMode.ExecutionAndPublication));
CurrentAccessSnapshot snapshot;
try
{
snapshot = await flight.Value;
}
finally
{
SnapshotFlights.TryRemove(new KeyValuePair<string, Lazy<Task<CurrentAccessSnapshot>>>(localKey, flight));
}
memoryCache.Set(localKey, snapshot, TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds));
try
{
await snapshotCache.SetAsync(realm, tenantId, userId,
new CachedAuthorizationSnapshot(version, snapshot), cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// PostgreSQL remains authoritative; a later request can refill Redis.
}
return snapshot;
}
private async Task<CurrentAccessSnapshot> LoadPermissionSnapshotAsync(
Guid userId, Guid? tenantId, CancellationToken cancellationToken)
{
if (tenantId is null)
{
return new CurrentAccessSnapshot(
userId, null, true, false, new HashSet<string>(StringComparer.Ordinal),
await LoadPlatformPermissionsAsync(userId, cancellationToken), CurrentDataScope.Self);
}
var roles = await (
from userRole in dbContext.TenantBackendUserRoles.AsNoTracking()
join role in dbContext.TenantBackendRoles.AsNoTracking() on userRole.RoleId equals role.Id
where userRole.TenantId == tenantId && userRole.UserId == userId && role.Status == BackendRoleStatus.Active
select new { role.Id, role.DataScope })
.ToArrayAsync(cancellationToken);
var roleIds = roles.Select(role => role.Id).ToArray();
var permissions = roleIds.Length == 0
? new HashSet<string>(StringComparer.Ordinal)
: (await (from binding in dbContext.TenantBackendRolePermissions.AsNoTracking()
join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals permission.Code
where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) &&
(permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both)
select binding.PermissionCode).Distinct().ToArrayAsync(cancellationToken))
.ToHashSet(StringComparer.Ordinal);
return new CurrentAccessSnapshot(
userId, tenantId, true, true, permissions, new HashSet<string>(StringComparer.Ordinal),
CurrentDataScope.Merge(roles.Select(role => role.DataScope)));
}
private CurrentAccessSnapshot Empty()
{
return new CurrentAccessSnapshot(

View File

@@ -0,0 +1,170 @@
using System.Text.Json;
using System.Diagnostics;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Security;
internal sealed class RedisAuthorizationCache(
IConnectionMultiplexer connection,
IOptions<AuthorizationCacheOptions> options,
string environmentName) : IAccessSecurityCache, IAuthorizationSnapshotCache
{
private const string SetVersionScript = """
local current = redis.call('GET', KEYS[1])
if current then
local decoded = cjson.decode(current)
if tonumber(decoded.version) > tonumber(ARGV[1]) then return 0 end
end
redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
return 1
""";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly AuthorizationCacheOptions options = options.Value;
private readonly string prefix = $"tiku:{Normalize(environmentName)}";
public bool IsConfigured => true;
public async Task<AccessSecurityCacheState?> GetAsync(
AccessSecurityCacheLookup lookup,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var keys = lookup.Realm == AuthRealm.Tenant
? new RedisKey[]
{
SessionKey(lookup.SessionId), UserKey(lookup.UserId), VersionKey(lookup.Realm, lookup.TenantId),
TenantKey(lookup.TenantId!.Value), MembershipKey(lookup.TenantId.Value, lookup.UserId)
}
: new RedisKey[]
{
SessionKey(lookup.SessionId), UserKey(lookup.UserId), VersionKey(lookup.Realm, null),
PlatformAccessKey(lookup.UserId)
};
var started = Stopwatch.GetTimestamp();
var values = await connection.GetDatabase().StringGetAsync(keys).WaitAsync(cancellationToken);
AuthorizationCacheTelemetry.RecordRedisDuration(Stopwatch.GetElapsedTime(started).TotalMilliseconds);
var state = new AccessSecurityCacheState(
Deserialize<CachedSessionSecurityState>(values[0]),
Deserialize<CachedUserSecurityState>(values[1]),
lookup.Realm == AuthRealm.Tenant ? Deserialize<CachedTenantSecurityState>(values[3]) : null,
lookup.Realm == AuthRealm.Tenant ? Deserialize<CachedMembershipSecurityState>(values[4]) : null,
lookup.Realm == AuthRealm.Platform ? Deserialize<CachedPlatformAccessState>(values[3]) : null,
Deserialize<CachedAuthorizationVersion>(values[2]));
AuthorizationCacheTelemetry.Read("redis_state", state.Complete);
return state.Complete ? state : null;
}
public async Task SetAsync(AccessSecurityCacheState state, CancellationToken cancellationToken = default)
{
var ttl = StateTtl();
var database = connection.GetDatabase();
var writes = new List<Task>();
Add(writes, database, state.Session is null ? default : SessionKey(state.Session.SessionId), state.Session, ttl);
Add(writes, database, state.User is null ? default : UserKey(state.User.UserId), state.User, ttl);
Add(writes, database, state.Tenant is null ? default : TenantKey(state.Tenant.TenantId), state.Tenant, ttl);
Add(writes, database, state.Membership is null ? default : MembershipKey(state.Membership.TenantId, state.Membership.UserId), state.Membership, ttl);
Add(writes, database, state.PlatformAccess is null ? default : PlatformAccessKey(state.PlatformAccess.UserId), state.PlatformAccess, ttl);
await Task.WhenAll(writes).WaitAsync(cancellationToken);
if (state.AuthorizationVersion is { } version)
{
await SetAuthorizationVersionAsync(
version.Realm, version.TenantId, version.Version, cancellationToken);
}
}
public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
DeleteAsync(SessionKey(sessionId), cancellationToken);
public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) =>
DeleteAsync(UserKey(userId), cancellationToken);
public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
DeleteAsync(TenantKey(tenantId), cancellationToken);
public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) =>
DeleteAsync(MembershipKey(tenantId, userId), cancellationToken);
public async Task SetAuthorizationVersionAsync(
AuthRealm realm, Guid? tenantId, long version, CancellationToken cancellationToken = default)
{
var value = new CachedAuthorizationVersion(realm, tenantId, version);
var ttl = StateTtl();
await connection.GetDatabase().ScriptEvaluateAsync(
SetVersionScript,
[VersionKey(realm, tenantId)],
[version, JsonSerializer.Serialize(value, SerializerOptions), (long)ttl.TotalMilliseconds])
.WaitAsync(cancellationToken);
}
async Task<CachedAuthorizationSnapshot?> IAuthorizationSnapshotCache.GetAsync(
AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken)
{
var value = await connection.GetDatabase().StringGetAsync(SnapshotKey(realm, tenantId, userId))
.WaitAsync(cancellationToken);
return Deserialize<CachedAuthorizationSnapshot>(value);
}
Task IAuthorizationSnapshotCache.SetAsync(
AuthRealm realm,
Guid? tenantId,
Guid userId,
CachedAuthorizationSnapshot snapshot,
CancellationToken cancellationToken) =>
SetValueAsync(
SnapshotKey(realm, tenantId, userId),
snapshot,
TimeSpan.FromSeconds(Math.Max(1, options.DistributedSnapshotSeconds)),
cancellationToken);
private async Task DeleteAsync(RedisKey key, CancellationToken cancellationToken) =>
await connection.GetDatabase().KeyDeleteAsync(key).WaitAsync(cancellationToken);
private async Task SetValueAsync<T>(RedisKey key, T value, TimeSpan ttl, CancellationToken cancellationToken) =>
await connection.GetDatabase().StringSetAsync(key, JsonSerializer.Serialize(value, SerializerOptions), ttl)
.WaitAsync(cancellationToken);
private static void Add<T>(List<Task> writes, IDatabase database, RedisKey key, T? value, TimeSpan ttl)
{
if (value is not null)
{
writes.Add(database.StringSetAsync(key, JsonSerializer.Serialize(value, SerializerOptions), ttl));
}
}
private TimeSpan StateTtl()
{
var seconds = Math.Max(1, options.DistributedStateSeconds);
var jitter = Math.Clamp(options.JitterPercent, 0, 50);
return TimeSpan.FromSeconds(seconds * (1 + Random.Shared.Next(-jitter, jitter + 1) / 100d));
}
private static T? Deserialize<T>(RedisValue value) => value.IsNullOrEmpty
? default
: JsonSerializer.Deserialize<T>(value.ToString(), SerializerOptions);
private RedisKey SessionKey(Guid id) => $"{prefix}:auth:session:v1:{id:N}";
private RedisKey UserKey(Guid id) => $"{prefix}:auth:user:v1:{id:N}";
private RedisKey TenantKey(Guid id) => $"{prefix}:auth:tenant:v1:{id:N}";
private RedisKey MembershipKey(Guid tenantId, Guid userId) => $"{prefix}:auth:membership:v1:{tenantId:N}:{userId:N}";
private RedisKey PlatformAccessKey(Guid userId) => $"{prefix}:auth:platform-access:v1:{userId:N}";
private RedisKey VersionKey(AuthRealm realm, Guid? tenantId) =>
$"{prefix}:authz:version:v1:{realm.ToString().ToLowerInvariant()}:{tenantId?.ToString("N") ?? "platform"}";
private RedisKey SnapshotKey(AuthRealm realm, Guid? tenantId, Guid userId) =>
$"{prefix}:authz:snapshot:v1:{realm.ToString().ToLowerInvariant()}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}";
private static string Normalize(string value) => new(value.Trim().ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) || character is '-' or '_' ? character : '-').ToArray());
}
internal sealed class NullAuthorizationCache : IAccessSecurityCache, IAuthorizationSnapshotCache
{
public bool IsConfigured => false;
public Task<AccessSecurityCacheState?> GetAsync(AccessSecurityCacheLookup lookup, CancellationToken cancellationToken = default) => Task.FromResult<AccessSecurityCacheState?>(null);
public Task SetAsync(AccessSecurityCacheState state, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task InvalidateSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task InvalidateUserAsync(Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task InvalidateTenantAsync(Guid tenantId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task InvalidateMembershipAsync(Guid tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task SetAuthorizationVersionAsync(AuthRealm realm, Guid? tenantId, long version, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<CachedAuthorizationSnapshot?> GetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CancellationToken cancellationToken = default) => Task.FromResult<CachedAuthorizationSnapshot?>(null);
public Task SetAsync(AuthRealm realm, Guid? tenantId, Guid userId, CachedAuthorizationSnapshot snapshot, CancellationToken cancellationToken = default) => Task.CompletedTask;
}

View File

@@ -0,0 +1,16 @@
using Tiku.Application.Auth;
using Tiku.Application.Security;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Security;
internal sealed class RequestAccessValidator(IAuthSessionStore sessionStore) : IRequestAccessValidator
{
public Task<AuthSessionValidationResult?> ValidateAsync(
Guid sessionId,
Guid userId,
AuthRealm realm,
Guid? tenantId,
CancellationToken cancellationToken = default) =>
sessionStore.ValidateAccessSessionAsync(sessionId, userId, realm, tenantId, cancellationToken);
}

View File

@@ -19,6 +19,7 @@ internal sealed class TenantLifecycleService(
ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
ITenantPublicCacheInvalidator publicCacheInvalidator,
ITenantFeatureCacheInvalidator featureCacheInvalidator,
IAuthorizationStateInvalidator authorizationStateInvalidator,
IObjectStorageService objectStorageService) : ITenantLifecycleService
{
private static readonly TimeSpan RecentExportWindow = TimeSpan.FromHours(24);
@@ -219,6 +220,12 @@ internal sealed class TenantLifecycleService(
AddAudit(tenantId, actorUserId, "tenant.owner_transferred", targetUserId, reason);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, tenantId, cancellationToken);
if (previousOwnerId.HasValue)
{
await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, previousOwnerId.Value, cancellationToken);
}
await authorizationStateInvalidator.InvalidateMembershipAsync(tenantId, targetUserId, cancellationToken);
await InvalidateAsync(tenantId, cancellationToken);
return ToItem(operation);
}
@@ -256,6 +263,7 @@ internal sealed class TenantLifecycleService(
await runtimeCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
await featureCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
await authorizationStateInvalidator.InvalidateTenantAsync(tenantId, cancellationToken);
}
private static TenantLifecycleOperation CreateOperation(

View File

@@ -29,7 +29,8 @@ public sealed class TenantAdminDirectService(
INotificationProvider notificationProvider,
ICurrentAccessContext currentAccessContext,
IAuthSessionStore sessionStore,
IFeatureAccessService featureAccessService) : ITenantAdminDirectService
IFeatureAccessService featureAccessService,
IAuthorizationStateInvalidator authorizationStateInvalidator) : ITenantAdminDirectService
{
public async Task<TenantAdminOverviewItem> GetOverviewAsync(
TenantAdminActor actor,
@@ -621,6 +622,8 @@ public sealed class TenantAdminDirectService(
{
await transaction.CommitAsync(cancellationToken);
}
await authorizationStateInvalidator.InvalidateMembershipAsync(
actor.TenantId, membership.UserId, cancellationToken);
return new ContentManagementResult<TenantAdminStudentStatusItem>(
new TenantAdminStudentStatusItem(membership.Id, membership.UserId, membership.Role, membership.Status, membership.UpdatedAt));
}
@@ -1282,6 +1285,9 @@ public sealed class TenantAdminDirectService(
{
await transaction.CommitAsync(cancellationToken);
}
await authorizationStateInvalidator.InvalidateMembershipAsync(
actor.TenantId, membership.UserId, cancellationToken);
await authorizationStateInvalidator.BumpScopeAsync(AuthRealm.Tenant, actor.TenantId, cancellationToken);
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
}
@@ -1330,6 +1336,8 @@ public sealed class TenantAdminDirectService(
{
await transaction.CommitAsync(cancellationToken);
}
await authorizationStateInvalidator.InvalidateMembershipAsync(
actor.TenantId, membership.UserId, cancellationToken);
var user = await dbContext.Users.AsNoTracking().SingleAsync(item => item.Id == membership.UserId, cancellationToken);
return new ContentManagementResult<TenantAdminMemberItem>(ToMemberItem(membership, user));
}