Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2386fb5
feat: let a module add middleware after routing and after authentication
alexeyshibanov Sep 5, 2026
1f6b846
feat: make the routing-to-authorization pipeline segment drivable by …
alexeyshibanov Sep 5, 2026
d9ba05e
feat: add a harness that drives the extracted pipeline segment over a…
alexeyshibanov Sep 5, 2026
e6559e1
feat: assert the endpoint and the authentication straddle the two hoo…
alexeyshibanov Sep 5, 2026
1d052b4
feat: assert the second hook sees rejected requests and the first see…
alexeyshibanov Sep 5, 2026
f83fc99
feat: assert a startup overriding neither new hook serves requests un…
alexeyshibanov Sep 5, 2026
ea48a03
feat: share the api key parameter name as a constant callers outside …
alexeyshibanov Sep 5, 2026
f9314d8
fix: stop an oversized api key candidate sizing its own cache entry
alexeyshibanov Sep 5, 2026
e09d22f
fix: assert a raw candidate cannot produce a digested candidate's cac…
alexeyshibanov Sep 5, 2026
0d52cb2
fix: expire an unresolved api key lookup in 30 seconds instead of the…
alexeyshibanov Sep 5, 2026
66dca1a
feat: name the guarding tests in the hook comments and split the harn…
alexeyshibanov Sep 5, 2026
00afe31
fix: pin the clamp against a longer configured expiration and tighten…
alexeyshibanov Sep 5, 2026
528ec89
fix: trim comments that restate the code or a test that already guard…
alexeyshibanov Sep 6, 2026
cc00227
fix: name the hook-free startup double and its tests by what they are…
alexeyshibanov Sep 6, 2026
07921d7
fix: say on each hook which middlewares precede it and that static-fi…
alexeyshibanov Sep 6, 2026
9e8d0a0
style: put each default hook implementation's braces on their own lines
alexeyshibanov Sep 7, 2026
cc55b80
fix: say at each site why the negative entry is bounded and why the h…
alexeyshibanov Sep 7, 2026
0b5bb14
test: drop the api key parameter name pin
alexeyshibanov Sep 7, 2026
6538ca8
test: build the harness policy through AddAuthorizationBuilder
alexeyshibanov Sep 7, 2026
edf1e22
test: drop the assertions on the interface's own shape
alexeyshibanov Sep 7, 2026
298b977
feat: give every IPlatformStartup member a default implementation
alexeyshibanov Sep 7, 2026
f1a4a78
test: assert the hook dispatch order instead of per-instance call counts
alexeyshibanov Sep 8, 2026
2a5fb4c
fix: keep a module with errors from contributing a platform startup
alexeyshibanov Sep 8, 2026
b31a3a6
test: drop the two api key cache-key cases that pass for the wrong re…
alexeyshibanov Sep 8, 2026
9271ac4
test: drop the no-hook startup differential and the harness it needed
alexeyshibanov Sep 8, 2026
62709dc
refactor: lift a module's startup resolution out of the discovery loop
alexeyshibanov Sep 8, 2026
7e4421b
Merge remote-tracking branch 'origin/dev' into feat/pipeline-hooks-an…
alexeyshibanov Sep 8, 2026
51bb3b5
refactor: let TryCreateStartup answer the whole question it is named for
alexeyshibanov Sep 8, 2026
4ecabdd
Merge branch 'dev' into feat/pipeline-hooks-and-credential-cache
OlegoO Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions src/VirtoCommerce.Platform.Core/Modularity/IPlatformStartup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,61 @@ public interface IPlatformStartup
/// Called during Program.cs ConfigureAppConfiguration phase.
/// Use to add configuration sources (e.g., Azure App Configuration).
/// </summary>
void ConfigureAppConfiguration(IConfigurationBuilder builder, IHostEnvironment env);
void ConfigureAppConfiguration(IConfigurationBuilder builder, IHostEnvironment env)
{
}

/// <summary>
/// Called during Program.cs ConfigureServices phase.
/// Use to register host-level services.
/// </summary>
void ConfigureHostServices(IServiceCollection services, IConfiguration config);
void ConfigureHostServices(IServiceCollection services, IConfiguration config)
{
}

/// <summary>
/// Called during Startup.ConfigureServices after modules are loaded.
/// Use for application-level service registration.
/// </summary>
void ConfigureServices(IServiceCollection services, IConfiguration config);
void ConfigureServices(IServiceCollection services, IConfiguration config)
{
}

/// <summary>
/// Called during Startup.Configure.
/// </summary>
void Configure(IApplicationBuilder app, IConfiguration config);
void Configure(IApplicationBuilder app, IConfiguration config)
{
}

/// <summary>
/// Called during Startup.Configure after UseRouting, UseStaticFiles and UseModulesAndAppsFiles, and before
/// UseAuthentication: the endpoint is matched (HttpContext.GetEndpoint() is still null when no route
/// matched) and the caller is not yet authenticated.
/// Use for middleware that needs the matched endpoint but must run before authentication.
/// </summary>
/// <remarks>
/// Requests for platform and module static files never get here: UseStaticFiles and
/// UseModulesAndAppsFiles serve the file and end the request first. Middleware registered here sees
/// API and page requests, not scripts, styles or images.
/// </remarks>
void ConfigureAfterRouting(IApplicationBuilder app, IConfiguration config)
{
}

/// <summary>
/// Called during Startup.Configure after UseAuthentication and UseAccountLockoutMiddleware, and before
/// UseAuthorization: HttpContext.User is set (it may be anonymous) and authorization has not run yet, so
/// the requests it is about to reject with 401 or 403 are still visible here.
/// Use for middleware that needs the principal and the matched endpoint and must also see the requests
/// authorization rejects.
/// </summary>
/// <remarks>
/// Requests for platform and module static files never get here: UseStaticFiles and
/// UseModulesAndAppsFiles serve the file and end the request first. Middleware registered here sees
/// API and page requests, not scripts, styles or images.
/// </remarks>
void ConfigureAfterAuthentication(IApplicationBuilder app, IConfiguration config)
{
}
}
8 changes: 8 additions & 0 deletions src/VirtoCommerce.Platform.Core/PlatformConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ public static class PlatformConstants

public static class Security
{
public static class ApiKeyAuthentication
{
/// <summary>
/// The query-string and header parameter name the API key authentication scheme reads.
/// </summary>
public const string ParamName = "api_key";
}

public static class AuthenticationSchemes
{
public const string MixedScheme = "VirtoMixedAuthenticationScheme";
Expand Down
100 changes: 69 additions & 31 deletions src/VirtoCommerce.Platform.Modules/ModuleBootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,22 @@ public void RunConfigure(IApplicationBuilder applicationBuilder, IConfiguration
}
}

public void RunConfigureAfterRouting(IApplicationBuilder applicationBuilder, IConfiguration configuration)
{
foreach (var startup in _startups)
{
startup.ConfigureAfterRouting(applicationBuilder, configuration);
}
}

public void RunConfigureAfterAuthentication(IApplicationBuilder applicationBuilder, IConfiguration configuration)
{
foreach (var startup in _startups)
{
startup.ConfigureAfterAuthentication(applicationBuilder, configuration);
}
}

#endregion

#region Private — Discovery
Expand Down Expand Up @@ -1198,54 +1214,76 @@ internal void RegisterModulesInternal(IList<ManifestModuleInfo> modules)

#region Private — Startup Discovery

private void DiscoverStartupsInternal(IList<ManifestModuleInfo> modules)
internal void DiscoverStartupsInternal(IList<ManifestModuleInfo> modules)
{
var startups = new List<IPlatformStartup>();
var logger = _loggerFactory.CreateLogger<ModuleBootstrapper>();

foreach (var module in modules)
{
if (string.IsNullOrEmpty(module.StartupType) || module.Assembly == null)
var startup = TryCreateStartup(module, logger);

if (startup != null)
{
continue;
startups.Add(startup);
}
}

try
{
var startupType = module.Assembly.GetType(module.StartupType) ??
FindTypeByName(module.Assembly, module.StartupType);
_startups = startups;
logger.LogDebug("Platform startup extensions: {StartupCount}", startups.Count);
}

if (startupType == null)
{
logger.LogWarning("Startup type '{StartupType}' not found in {ModuleId}", module.StartupType, module.Id);
continue;
}
private IPlatformStartup TryCreateStartup(ManifestModuleInfo module, ILogger logger)
{
// A module the platform will not initialize must not contribute middleware either. Its assembly is
// loaded whatever its errors, and InitializeModules skips it, so its services never reach the
// container while the hooks it registers still run and resolve them. Validation has already named
// the module and its errors in the log; a second record here would add only a line.
if (module.Errors.Count > 0)
{
return null;
}

if (!typeof(IPlatformStartup).IsAssignableFrom(startupType))
{
logger.LogWarning("Type '{StartupType}' does not implement IPlatformStartup in {ModuleId}", module.StartupType, module.Id);
continue;
}
if (string.IsNullOrEmpty(module.StartupType) || module.Assembly == null)
{
return null;
}

if (Activator.CreateInstance(startupType) is IPlatformStartup instance)
{
if (instance is IHasLogger hasLogger)
{
hasLogger.Logger = _loggerFactory.CreateLogger(startupType);
}
try
{
var startupType = module.Assembly.GetType(module.StartupType) ??
FindTypeByName(module.Assembly, module.StartupType);

startups.Add(instance);
logger.LogInformation("Discovered {StartupTypeName} from {ModuleId}", startupType.Name, module.Id);
}
if (startupType == null)
{
logger.LogWarning("Startup type '{StartupType}' not found in {ModuleId}", module.StartupType, module.Id);
return null;
}
catch (Exception ex)

if (!typeof(IPlatformStartup).IsAssignableFrom(startupType))
{
logger.LogError(ex, "Error loading startup type from {ModuleId}", module.Id);
logger.LogWarning("Type '{StartupType}' does not implement IPlatformStartup in {ModuleId}", module.StartupType, module.Id);
return null;
}
}

_startups = startups;
logger.LogDebug("Platform startup extensions: {StartupCount}", startups.Count);
if (Activator.CreateInstance(startupType) is not IPlatformStartup instance)
{
return null;
}

if (instance is IHasLogger hasLogger)
{
hasLogger.Logger = _loggerFactory.CreateLogger(startupType);
}

logger.LogInformation("Discovered {StartupTypeName} from {ModuleId}", startupType.Name, module.Id);
return instance;
}
catch (Exception ex)
{
logger.LogError(ex, "Error loading startup type from {ModuleId}", module.Id);
return null;
}
}

private static Type FindTypeByName(Assembly assembly, string typeName)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using VirtoCommerce.Platform.Core.Caching;
using VirtoCommerce.Platform.Core.Common;
using VirtoCommerce.Platform.Core.Security;
using VirtoCommerce.Platform.Data.Infrastructure;
using VirtoCommerce.Platform.Security.Caching;
using VirtoCommerce.Platform.Security.Model;
using VirtoCommerce.Platform.Security.Repositories;
Expand All @@ -14,6 +17,13 @@ namespace VirtoCommerce.Platform.Security.Services
{
public class UserApiKeyService : IUserApiKeyService
{
// Own key component rather than a prefix on the value: CacheKey.With joins positionally, so
// "0-<raw>" and "1-<digest>" cannot collide whatever the caller sends.
private const string RawKeyDiscriminator = "0";
private const string DigestedKeyDiscriminator = "1";

private static readonly TimeSpan _missingApiKeyExpiration = TimeSpan.FromSeconds(30);

private readonly Func<ISecurityRepository> _repositoryFactory;
private readonly IPlatformMemoryCache _memoryCache;

Expand All @@ -25,7 +35,7 @@ public UserApiKeyService(Func<ISecurityRepository> repositoryFactory, IPlatformM

public async Task<UserApiKey> GetApiKeyByKeyAsync(string apiKey)
{
var cacheKey = CacheKey.With(GetType(), nameof(GetApiKeyByKeyAsync), apiKey);
var cacheKey = BuildApiKeyCacheKey(apiKey);
return await _memoryCache.GetOrCreateExclusiveAsync(cacheKey, async (cacheEntry) =>
{
//Add cache expiration token
Expand All @@ -35,11 +45,42 @@ public async Task<UserApiKey> GetApiKeyByKeyAsync(string apiKey)
var result = await repository.UserApiKeys.Where(x => x.ApiKey == apiKey)
.AsNoTracking()
.FirstOrDefaultAsync();
if (result == null)
{
// A miss is keyed on a string the caller chooses, so its entry must not be renewable
// from outside. Clamped, not assigned: a deployment configuring CacheAbsoluteExpiration
// below this bound must not have its negative entries lengthened. Nulled, not left
// alone: the entry arrives sliding-only, and a sliding window shorter than the bound
// would drop the entry between probes and send every probe back to the repository.
cacheEntry.AbsoluteExpirationRelativeToNow = ShorterOf(cacheEntry.AbsoluteExpirationRelativeToNow, _missingApiKeyExpiration);
cacheEntry.SlidingExpiration = null;
}

return result?.ToModel(AbstractTypeFactory<UserApiKey>.TryCreateInstance());
}
});
}

private string BuildApiKeyCacheKey(string apiKey)
{
// The threshold is the ApiKey column's HasMaxLength; the repository is still queried with the
// candidate as presented.
if (apiKey == null || apiKey.Length <= DbContextBase.Length128)
{
return CacheKey.With(GetType(), nameof(GetApiKeyByKeyAsync), RawKeyDiscriminator, apiKey);
}

// Hex, not Base64: CacheKey.Normalize lower-cases the key downstream.
var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(apiKey)));

return CacheKey.With(GetType(), nameof(GetApiKeyByKeyAsync), DigestedKeyDiscriminator, digest);
}

private static TimeSpan ShorterOf(TimeSpan? configured, TimeSpan bound)
{
return configured.HasValue && configured.Value < bound ? configured.Value : bound;
}

public async Task<UserApiKey[]> GetAllUserApiKeysAsync(string userId)
{
var cacheKey = CacheKey.With(GetType(), nameof(GetAllUserApiKeysAsync), userId);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication;
using VirtoCommerce.Platform.Core;

namespace VirtoCommerce.Platform.Web.Security.Authentication
{
Expand All @@ -7,6 +8,6 @@ public class ApiKeyAuthenticationOptions : AuthenticationSchemeOptions
public const string DefaultScheme = "API Key";

public string Scheme { get; set; } = DefaultScheme;
public string ApiKeyParamName { get; set; } = "api_key";
public string ApiKeyParamName { get; set; } = PlatformConstants.Security.ApiKeyAuthentication.ParamName;
}
}
Loading
Loading