diff --git a/Tiku.Api/Background/DevelopmentTenantDomainLifecycleHostedService.cs b/Tiku.Api/Background/DevelopmentTenantDomainLifecycleHostedService.cs index a439145..df245d3 100644 --- a/Tiku.Api/Background/DevelopmentTenantDomainLifecycleHostedService.cs +++ b/Tiku.Api/Background/DevelopmentTenantDomainLifecycleHostedService.cs @@ -15,7 +15,12 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService( { if (!options.EnableDevelopmentLocalhostBypass) return; - var interval = TimeSpan.FromSeconds(Math.Clamp(options.PollSeconds, 1, 3600)); + var activeInterval = TimeSpan.FromSeconds(Math.Clamp(options.PollSeconds, 1, 3600)); + var maxIdleInterval = TimeSpan.FromSeconds(Math.Clamp( + options.MaxIdlePollSeconds, + (int)activeInterval.TotalSeconds, + 3600)); + var consecutiveIdleIterations = 0; while (!stoppingToken.IsCancellationRequested) { try @@ -26,6 +31,7 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService( var processed = await scope.ServiceProvider .GetRequiredService() .ProcessPendingAsync(stoppingToken); + consecutiveIdleIterations = processed == 0 ? consecutiveIdleIterations + 1 : 0; if (processed > 0) logger.LogInformation("Processed {DomainCount} pending Development tenant domains.", processed); } @@ -35,10 +41,25 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService( } catch (Exception exception) { + consecutiveIdleIterations++; logger.LogError(exception, "Development tenant domain lifecycle iteration failed."); } - await Task.Delay(interval, stoppingToken); + await Task.Delay( + CalculateDelay(activeInterval, maxIdleInterval, consecutiveIdleIterations), + stoppingToken); } } -} \ No newline at end of file + + internal static TimeSpan CalculateDelay( + TimeSpan activeInterval, + TimeSpan maxIdleInterval, + int consecutiveIdleIterations) + { + if (consecutiveIdleIterations <= 1) return activeInterval; + + var shift = Math.Min(consecutiveIdleIterations - 1, 20); + var delayTicks = activeInterval.Ticks * (1L << shift); + return TimeSpan.FromTicks(Math.Min(delayTicks, maxIdleInterval.Ticks)); + } +} diff --git a/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs index 6f1df3f..cbcb2c9 100644 --- a/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs +++ b/Tiku.Api/Configuration/NetworkConfigurationExtensions.cs @@ -42,6 +42,10 @@ internal static class NetworkConfigurationExtensions .Bind(configuration.GetSection("TenantDomains")) .Validate(options => environment.IsDevelopment() || !options.EnableDevelopmentLocalhostBypass, "The .localhost domain lifecycle bypass can only be enabled in Development.") + .Validate(options => options.PollSeconds is >= 1 and <= 3600 && + options.MaxIdlePollSeconds is >= 1 and <= 3600 && + options.MaxIdlePollSeconds >= options.PollSeconds, + "Tenant domain polling intervals are invalid.") .ValidateOnStart(); if (environment.IsDevelopment()) services.AddHostedService(); services.AddOptions() @@ -111,4 +115,4 @@ internal static class NetworkConfigurationExtensions (developmentOrigin.Scheme == Uri.UriSchemeHttp || developmentOrigin.Scheme == Uri.UriSchemeHttps); } -} \ No newline at end of file +} diff --git a/Tiku.Api/Tiku.Api.csproj b/Tiku.Api/Tiku.Api.csproj index bfe9423..0afa896 100644 --- a/Tiku.Api/Tiku.Api.csproj +++ b/Tiku.Api/Tiku.Api.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/Tiku.Api/appsettings.Development.json b/Tiku.Api/appsettings.Development.json index e680379..03daa17 100644 --- a/Tiku.Api/appsettings.Development.json +++ b/Tiku.Api/appsettings.Development.json @@ -26,6 +26,7 @@ }, "TenantDomains": { "PollSeconds": 2, + "MaxIdlePollSeconds": 300, "EnableDevelopmentLocalhostBypass": true }, "TenantProvisioning": { diff --git a/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs b/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs index bd47700..2241a62 100644 --- a/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs +++ b/Tiku.Application/Tenancy/TenantDomainLifecycleModels.cs @@ -4,6 +4,7 @@ public sealed class DomainLifecycleOptions { public bool Enabled { get; set; } = true; public int PollSeconds { get; set; } = 60; + public int MaxIdlePollSeconds { get; set; } = 300; public int BatchSize { get; set; } = 50; public string DnsJsonEndpoint { get; set; } = "https://cloudflare-dns.com/dns-query"; public string VerificationRecordPrefix { get; set; } = "_tiku-verification"; @@ -38,4 +39,4 @@ public interface ITenantDomainLifecycleService public interface ITenantRuntimeCacheInvalidator { Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/Tiku.Infrastructure/Security/CurrentAccessContext.cs b/Tiku.Infrastructure/Security/CurrentAccessContext.cs index dc702c4..bc28355 100644 --- a/Tiku.Infrastructure/Security/CurrentAccessContext.cs +++ b/Tiku.Infrastructure/Security/CurrentAccessContext.cs @@ -38,12 +38,13 @@ 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( + if (isValidated) + return await LoadVersionedSnapshotAsync( userId, tenantContext.TenantId, validatedSession!.Realm, validatedSession.AuthorizationVersion, + cacheOptions.Value.Mode == AuthorizationCacheMode.Active, cancellationToken); if (!isValidated) { @@ -148,8 +149,13 @@ internal sealed class CurrentAccessContext( .ToHashSet(StringComparer.Ordinal); } - private async Task LoadCachedSnapshotAsync( - Guid userId, Guid? tenantId, AuthRealm realm, long version, CancellationToken cancellationToken) + private async Task LoadVersionedSnapshotAsync( + Guid userId, + Guid? tenantId, + AuthRealm realm, + long version, + bool useDistributedCache, + CancellationToken cancellationToken) { var localKey = $"authorization-snapshot:v1:{realm}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}:{version}"; @@ -161,26 +167,30 @@ internal sealed class CurrentAccessContext( AuthorizationCacheTelemetry.Read("l1_snapshot", false); - try + if (useDistributedCache) { - var distributed = await snapshotCache.GetAsync(realm, tenantId, userId, cancellationToken); - if (distributed is not null && distributed.Version == version) + try { - AuthorizationCacheTelemetry.Read("redis_snapshot", true); - memoryCache.Set(localKey, distributed.Snapshot, - TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds)); - return distributed.Snapshot; + 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. } - 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.Read("redis_snapshot", false); - AuthorizationCacheTelemetry.PostgresFallback(); + if (useDistributedCache) AuthorizationCacheTelemetry.PostgresFallback(); var flight = SnapshotFlights.GetOrAdd(localKey, _ => new Lazy>( () => LoadPermissionSnapshotAsync(userId, tenantId, CancellationToken.None), LazyThreadSafetyMode.ExecutionAndPublication)); @@ -195,14 +205,17 @@ internal sealed class CurrentAccessContext( } memoryCache.Set(localKey, snapshot, TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds)); - try + if (useDistributedCache) { - 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. + 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; @@ -227,11 +240,11 @@ internal sealed class CurrentAccessContext( var permissions = roleIds.Length == 0 ? new HashSet(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)) + 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(StringComparer.Ordinal), @@ -249,4 +262,4 @@ internal sealed class CurrentAccessContext( new HashSet(StringComparer.Ordinal), CurrentDataScope.Self); } -} \ No newline at end of file +} diff --git a/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs index f3e1030..b9965a1 100644 --- a/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs +++ b/Tiku.IntegrationTests/Api/BackofficeUiBootstrapTests.cs @@ -1,10 +1,13 @@ using System.Net; using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Tiku.Application.Security; using Tiku.Domain.Common; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; +using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; @@ -144,9 +147,25 @@ public sealed class BackofficeUiBootstrapTests commandRecorder.Reset(); using var response = await client.GetAsync("/api/platform/access/ui-bootstrap"); using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - var commands = commandRecorder.Snapshot(); + var firstRequestCommands = commandRecorder.Snapshot(); + commandRecorder.Reset(); + using var repeatedResponse = await client.GetAsync("/api/platform/access/ui-bootstrap"); + var repeatedRequestCommands = commandRecorder.Snapshot(); + + using (var scope = factory.CreateSystemScope("Revoke cached platform UI permission")) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + var permission = await dbContext.PlatformBackendRolePermissions.SingleAsync(item => + item.RoleId == roleId && item.PermissionCode == BackendPermissions.PlatformDashboardView); + dbContext.Remove(permission); + await dbContext.SaveChangesAsync(); + } + + using var revokedResponse = await client.GetAsync("/api/platform/access/ui-bootstrap"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, repeatedResponse.StatusCode); + Assert.Equal(HttpStatusCode.Unauthorized, revokedResponse.StatusCode); Assert.Equal( [BackendPermissions.PlatformDashboardView], bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString())); @@ -154,8 +173,11 @@ public sealed class BackofficeUiBootstrapTests ["platform.dashboard"], bootstrap.RootElement.GetProperty("menus").EnumerateArray() .Select(item => item.GetProperty("code").GetString())); - Assert.InRange(commands.Count, 1, 10); - AssertNoPerCodeCatalogExistenceQueries(commands); + Assert.InRange(firstRequestCommands.Count, 1, 10); + Assert.Contains(firstRequestCommands, IsPlatformPermissionQuery); + Assert.DoesNotContain(repeatedRequestCommands, IsPlatformPermissionQuery); + AssertNoPerCodeCatalogExistenceQueries(firstRequestCommands); + AssertNoPerCodeCatalogExistenceQueries(repeatedRequestCommands); } private static void AssertNoPerCodeCatalogExistenceQueries(IEnumerable commands) @@ -165,4 +187,10 @@ public sealed class BackofficeUiBootstrapTests command.TrimStart().StartsWith("SELECT EXISTS", StringComparison.OrdinalIgnoreCase) && catalogTables.Any(table => command.Contains(table, StringComparison.OrdinalIgnoreCase))); } -} \ No newline at end of file + + private static bool IsPlatformPermissionQuery(string command) + { + return command.Contains("platform_backend_role_permissions", StringComparison.OrdinalIgnoreCase) && + command.Contains("SELECT DISTINCT", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/Tiku.IntegrationTests/Api/DevelopmentTenantDomainLifecycleHostedServiceTests.cs b/Tiku.IntegrationTests/Api/DevelopmentTenantDomainLifecycleHostedServiceTests.cs new file mode 100644 index 0000000..c73de82 --- /dev/null +++ b/Tiku.IntegrationTests/Api/DevelopmentTenantDomainLifecycleHostedServiceTests.cs @@ -0,0 +1,24 @@ +using Tiku.Api.Background; + +namespace Tiku.IntegrationTests.Api; + +public sealed class DevelopmentTenantDomainLifecycleHostedServiceTests +{ + [Theory] + [InlineData(0, 2)] + [InlineData(1, 2)] + [InlineData(2, 4)] + [InlineData(3, 8)] + [InlineData(8, 256)] + [InlineData(9, 300)] + [InlineData(100, 300)] + public void Idle_polling_backs_off_exponentially_and_is_capped(int idleIterations, int expectedSeconds) + { + var delay = DevelopmentTenantDomainLifecycleHostedService.CalculateDelay( + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(300), + idleIterations); + + Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), delay); + } +}