This commit is contained in:
@@ -15,7 +15,12 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService(
|
|||||||
{
|
{
|
||||||
if (!options.EnableDevelopmentLocalhostBypass) return;
|
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)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -26,6 +31,7 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService(
|
|||||||
var processed = await scope.ServiceProvider
|
var processed = await scope.ServiceProvider
|
||||||
.GetRequiredService<ITenantDomainLifecycleService>()
|
.GetRequiredService<ITenantDomainLifecycleService>()
|
||||||
.ProcessPendingAsync(stoppingToken);
|
.ProcessPendingAsync(stoppingToken);
|
||||||
|
consecutiveIdleIterations = processed == 0 ? consecutiveIdleIterations + 1 : 0;
|
||||||
if (processed > 0)
|
if (processed > 0)
|
||||||
logger.LogInformation("Processed {DomainCount} pending Development tenant domains.", processed);
|
logger.LogInformation("Processed {DomainCount} pending Development tenant domains.", processed);
|
||||||
}
|
}
|
||||||
@@ -35,10 +41,25 @@ internal sealed class DevelopmentTenantDomainLifecycleHostedService(
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
consecutiveIdleIterations++;
|
||||||
logger.LogError(exception, "Development tenant domain lifecycle iteration failed.");
|
logger.LogError(exception, "Development tenant domain lifecycle iteration failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(interval, stoppingToken);
|
await Task.Delay(
|
||||||
|
CalculateDelay(activeInterval, maxIdleInterval, consecutiveIdleIterations),
|
||||||
|
stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ internal static class NetworkConfigurationExtensions
|
|||||||
.Bind(configuration.GetSection("TenantDomains"))
|
.Bind(configuration.GetSection("TenantDomains"))
|
||||||
.Validate(options => environment.IsDevelopment() || !options.EnableDevelopmentLocalhostBypass,
|
.Validate(options => environment.IsDevelopment() || !options.EnableDevelopmentLocalhostBypass,
|
||||||
"The .localhost domain lifecycle bypass can only be enabled in Development.")
|
"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();
|
.ValidateOnStart();
|
||||||
if (environment.IsDevelopment()) services.AddHostedService<DevelopmentTenantDomainLifecycleHostedService>();
|
if (environment.IsDevelopment()) services.AddHostedService<DevelopmentTenantDomainLifecycleHostedService>();
|
||||||
services.AddOptions<TenantProvisioningOptions>()
|
services.AddOptions<TenantProvisioningOptions>()
|
||||||
@@ -111,4 +115,4 @@ internal static class NetworkConfigurationExtensions
|
|||||||
(developmentOrigin.Scheme == Uri.UriSchemeHttp ||
|
(developmentOrigin.Scheme == Uri.UriSchemeHttp ||
|
||||||
developmentOrigin.Scheme == Uri.UriSchemeHttps);
|
developmentOrigin.Scheme == Uri.UriSchemeHttps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj"/>
|
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="Tiku.IntegrationTests"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi"/>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"TenantDomains": {
|
"TenantDomains": {
|
||||||
"PollSeconds": 2,
|
"PollSeconds": 2,
|
||||||
|
"MaxIdlePollSeconds": 300,
|
||||||
"EnableDevelopmentLocalhostBypass": true
|
"EnableDevelopmentLocalhostBypass": true
|
||||||
},
|
},
|
||||||
"TenantProvisioning": {
|
"TenantProvisioning": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public sealed class DomainLifecycleOptions
|
|||||||
{
|
{
|
||||||
public bool Enabled { get; set; } = true;
|
public bool Enabled { get; set; } = true;
|
||||||
public int PollSeconds { get; set; } = 60;
|
public int PollSeconds { get; set; } = 60;
|
||||||
|
public int MaxIdlePollSeconds { get; set; } = 300;
|
||||||
public int BatchSize { get; set; } = 50;
|
public int BatchSize { get; set; } = 50;
|
||||||
public string DnsJsonEndpoint { get; set; } = "https://cloudflare-dns.com/dns-query";
|
public string DnsJsonEndpoint { get; set; } = "https://cloudflare-dns.com/dns-query";
|
||||||
public string VerificationRecordPrefix { get; set; } = "_tiku-verification";
|
public string VerificationRecordPrefix { get; set; } = "_tiku-verification";
|
||||||
@@ -38,4 +39,4 @@ public interface ITenantDomainLifecycleService
|
|||||||
public interface ITenantRuntimeCacheInvalidator
|
public interface ITenantRuntimeCacheInvalidator
|
||||||
{
|
{
|
||||||
Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default);
|
Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ internal sealed class CurrentAccessContext(
|
|||||||
var isValidated = validatedSession is not null &&
|
var isValidated = validatedSession is not null &&
|
||||||
validatedSession.UserId == userId &&
|
validatedSession.UserId == userId &&
|
||||||
validatedSession.TenantId == tenantContext.TenantId;
|
validatedSession.TenantId == tenantContext.TenantId;
|
||||||
if (isValidated && cacheOptions.Value.Mode == AuthorizationCacheMode.Active)
|
if (isValidated)
|
||||||
return await LoadCachedSnapshotAsync(
|
return await LoadVersionedSnapshotAsync(
|
||||||
userId,
|
userId,
|
||||||
tenantContext.TenantId,
|
tenantContext.TenantId,
|
||||||
validatedSession!.Realm,
|
validatedSession!.Realm,
|
||||||
validatedSession.AuthorizationVersion,
|
validatedSession.AuthorizationVersion,
|
||||||
|
cacheOptions.Value.Mode == AuthorizationCacheMode.Active,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (!isValidated)
|
if (!isValidated)
|
||||||
{
|
{
|
||||||
@@ -148,8 +149,13 @@ internal sealed class CurrentAccessContext(
|
|||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<CurrentAccessSnapshot> LoadCachedSnapshotAsync(
|
private async Task<CurrentAccessSnapshot> LoadVersionedSnapshotAsync(
|
||||||
Guid userId, Guid? tenantId, AuthRealm realm, long version, CancellationToken cancellationToken)
|
Guid userId,
|
||||||
|
Guid? tenantId,
|
||||||
|
AuthRealm realm,
|
||||||
|
long version,
|
||||||
|
bool useDistributedCache,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var localKey =
|
var localKey =
|
||||||
$"authorization-snapshot:v1:{realm}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}:{version}";
|
$"authorization-snapshot:v1:{realm}:{tenantId?.ToString("N") ?? "platform"}:{userId:N}:{version}";
|
||||||
@@ -161,26 +167,30 @@ internal sealed class CurrentAccessContext(
|
|||||||
|
|
||||||
AuthorizationCacheTelemetry.Read("l1_snapshot", false);
|
AuthorizationCacheTelemetry.Read("l1_snapshot", false);
|
||||||
|
|
||||||
try
|
if (useDistributedCache)
|
||||||
{
|
{
|
||||||
var distributed = await snapshotCache.GetAsync(realm, tenantId, userId, cancellationToken);
|
try
|
||||||
if (distributed is not null && distributed.Version == version)
|
|
||||||
{
|
{
|
||||||
AuthorizationCacheTelemetry.Read("redis_snapshot", true);
|
var distributed = await snapshotCache.GetAsync(realm, tenantId, userId, cancellationToken);
|
||||||
memoryCache.Set(localKey, distributed.Snapshot,
|
if (distributed is not null && distributed.Version == version)
|
||||||
TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds));
|
{
|
||||||
return distributed.Snapshot;
|
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();
|
AuthorizationCacheTelemetry.Read("redis_snapshot", false);
|
||||||
}
|
|
||||||
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);
|
if (useDistributedCache) AuthorizationCacheTelemetry.PostgresFallback();
|
||||||
AuthorizationCacheTelemetry.PostgresFallback();
|
|
||||||
var flight = SnapshotFlights.GetOrAdd(localKey, _ => new Lazy<Task<CurrentAccessSnapshot>>(
|
var flight = SnapshotFlights.GetOrAdd(localKey, _ => new Lazy<Task<CurrentAccessSnapshot>>(
|
||||||
() => LoadPermissionSnapshotAsync(userId, tenantId, CancellationToken.None),
|
() => LoadPermissionSnapshotAsync(userId, tenantId, CancellationToken.None),
|
||||||
LazyThreadSafetyMode.ExecutionAndPublication));
|
LazyThreadSafetyMode.ExecutionAndPublication));
|
||||||
@@ -195,14 +205,17 @@ internal sealed class CurrentAccessContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
memoryCache.Set(localKey, snapshot, TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds));
|
memoryCache.Set(localKey, snapshot, TimeSpan.FromSeconds(cacheOptions.Value.LocalSnapshotSeconds));
|
||||||
try
|
if (useDistributedCache)
|
||||||
{
|
{
|
||||||
await snapshotCache.SetAsync(realm, tenantId, userId,
|
try
|
||||||
new CachedAuthorizationSnapshot(version, snapshot), cancellationToken);
|
{
|
||||||
}
|
await snapshotCache.SetAsync(realm, tenantId, userId,
|
||||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
new CachedAuthorizationSnapshot(version, snapshot), cancellationToken);
|
||||||
{
|
}
|
||||||
// PostgreSQL remains authoritative; a later request can refill Redis.
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// PostgreSQL remains authoritative; a later request can refill Redis.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@@ -227,11 +240,11 @@ internal sealed class CurrentAccessContext(
|
|||||||
var permissions = roleIds.Length == 0
|
var permissions = roleIds.Length == 0
|
||||||
? new HashSet<string>(StringComparer.Ordinal)
|
? new HashSet<string>(StringComparer.Ordinal)
|
||||||
: (await (from binding in dbContext.TenantBackendRolePermissions.AsNoTracking()
|
: (await (from binding in dbContext.TenantBackendRolePermissions.AsNoTracking()
|
||||||
join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals
|
join permission in dbContext.BackendPermissions.AsNoTracking() on binding.PermissionCode equals
|
||||||
permission.Code
|
permission.Code
|
||||||
where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) &&
|
where binding.TenantId == tenantId && roleIds.Contains(binding.RoleId) &&
|
||||||
(permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both)
|
(permission.Area == BackendPermissionArea.Tenant || permission.Area == BackendPermissionArea.Both)
|
||||||
select binding.PermissionCode).Distinct().ToArrayAsync(cancellationToken))
|
select binding.PermissionCode).Distinct().ToArrayAsync(cancellationToken))
|
||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
return new CurrentAccessSnapshot(
|
return new CurrentAccessSnapshot(
|
||||||
userId, tenantId, true, true, permissions, new HashSet<string>(StringComparer.Ordinal),
|
userId, tenantId, true, true, permissions, new HashSet<string>(StringComparer.Ordinal),
|
||||||
@@ -249,4 +262,4 @@ internal sealed class CurrentAccessContext(
|
|||||||
new HashSet<string>(StringComparer.Ordinal),
|
new HashSet<string>(StringComparer.Ordinal),
|
||||||
CurrentDataScope.Self);
|
CurrentDataScope.Self);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Tiku.Application.Security;
|
using Tiku.Application.Security;
|
||||||
using Tiku.Domain.Common;
|
using Tiku.Domain.Common;
|
||||||
using Tiku.Domain.Identity;
|
using Tiku.Domain.Identity;
|
||||||
using Tiku.Domain.Operations;
|
using Tiku.Domain.Operations;
|
||||||
using Tiku.Domain.Tenancy;
|
using Tiku.Domain.Tenancy;
|
||||||
|
using Tiku.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace Tiku.IntegrationTests.Api;
|
namespace Tiku.IntegrationTests.Api;
|
||||||
|
|
||||||
@@ -144,9 +147,25 @@ public sealed class BackofficeUiBootstrapTests
|
|||||||
commandRecorder.Reset();
|
commandRecorder.Reset();
|
||||||
using var response = await client.GetAsync("/api/platform/access/ui-bootstrap");
|
using var response = await client.GetAsync("/api/platform/access/ui-bootstrap");
|
||||||
using var bootstrap = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
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<TikuDbContext>();
|
||||||
|
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, response.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.OK, repeatedResponse.StatusCode);
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, revokedResponse.StatusCode);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
[BackendPermissions.PlatformDashboardView],
|
[BackendPermissions.PlatformDashboardView],
|
||||||
bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString()));
|
bootstrap.RootElement.GetProperty("permissionCodes").EnumerateArray().Select(item => item.GetString()));
|
||||||
@@ -154,8 +173,11 @@ public sealed class BackofficeUiBootstrapTests
|
|||||||
["platform.dashboard"],
|
["platform.dashboard"],
|
||||||
bootstrap.RootElement.GetProperty("menus").EnumerateArray()
|
bootstrap.RootElement.GetProperty("menus").EnumerateArray()
|
||||||
.Select(item => item.GetProperty("code").GetString()));
|
.Select(item => item.GetProperty("code").GetString()));
|
||||||
Assert.InRange(commands.Count, 1, 10);
|
Assert.InRange(firstRequestCommands.Count, 1, 10);
|
||||||
AssertNoPerCodeCatalogExistenceQueries(commands);
|
Assert.Contains(firstRequestCommands, IsPlatformPermissionQuery);
|
||||||
|
Assert.DoesNotContain(repeatedRequestCommands, IsPlatformPermissionQuery);
|
||||||
|
AssertNoPerCodeCatalogExistenceQueries(firstRequestCommands);
|
||||||
|
AssertNoPerCodeCatalogExistenceQueries(repeatedRequestCommands);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AssertNoPerCodeCatalogExistenceQueries(IEnumerable<string> commands)
|
private static void AssertNoPerCodeCatalogExistenceQueries(IEnumerable<string> commands)
|
||||||
@@ -165,4 +187,10 @@ public sealed class BackofficeUiBootstrapTests
|
|||||||
command.TrimStart().StartsWith("SELECT EXISTS", StringComparison.OrdinalIgnoreCase) &&
|
command.TrimStart().StartsWith("SELECT EXISTS", StringComparison.OrdinalIgnoreCase) &&
|
||||||
catalogTables.Any(table => command.Contains(table, StringComparison.OrdinalIgnoreCase)));
|
catalogTables.Any(table => command.Contains(table, StringComparison.OrdinalIgnoreCase)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private static bool IsPlatformPermissionQuery(string command)
|
||||||
|
{
|
||||||
|
return command.Contains("platform_backend_role_permissions", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
command.Contains("SELECT DISTINCT", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user