diff --git a/src/modules/Elsa.Identity/Extensions/ApplicationProviderExtensions.cs b/src/modules/Elsa.Identity/Extensions/ApplicationProviderExtensions.cs index 5de5515cf..a99a7e3cf 100644 --- a/src/modules/Elsa.Identity/Extensions/ApplicationProviderExtensions.cs +++ b/src/modules/Elsa.Identity/Extensions/ApplicationProviderExtensions.cs @@ -15,15 +15,17 @@ public static class ApplicationProviderExtensions /// /// The user provider. /// The client ID to search by. + /// Whether to bypass tenant scoping when resolving the application. /// The cancellation token. /// The application with the specified client ID. - public static async Task FindByClientIdAsync(this IApplicationProvider applicationProvider, string clientId, CancellationToken cancellationToken = default) + public static async Task FindByClientIdAsync(this IApplicationProvider applicationProvider, string clientId, bool tenantAgnostic = false, CancellationToken cancellationToken = default) { var filter = new ApplicationFilter { - ClientId = clientId + ClientId = clientId, + TenantAgnostic = tenantAgnostic }; return await applicationProvider.FindAsync(filter, cancellationToken); } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/Models/ApplicationFilter.cs b/src/modules/Elsa.Identity/Models/ApplicationFilter.cs index 9de2b4ad4..c19d7c5f2 100644 --- a/src/modules/Elsa.Identity/Models/ApplicationFilter.cs +++ b/src/modules/Elsa.Identity/Models/ApplicationFilter.cs @@ -21,6 +21,11 @@ public class ApplicationFilter /// Gets or sets the application name to filter for. /// public string? Name { get; set; } + + /// + /// Gets or sets a value indicating whether to ignore tenant scoping when querying the application store. + /// + public bool TenantAgnostic { get; set; } /// /// Applies the filter to the specified queryable. @@ -36,4 +41,4 @@ public class ApplicationFilter return queryable; } -} \ No newline at end of file +} diff --git a/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs b/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs index 6ddfe646f..2c19e2198 100644 --- a/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs +++ b/src/modules/Elsa.Identity/Services/DefaultApplicationCredentialsValidator.cs @@ -50,7 +50,7 @@ public class DefaultApplicationCredentialsValidator : IApplicationCredentialsVal return null; var clientId = _apiKeyParser.Parse(apiKey); - var application = await _applicationProvider.FindByClientIdAsync(clientId, cancellationToken); + var application = await _applicationProvider.FindByClientIdAsync(clientId, tenantAgnostic: true, cancellationToken); if(application == null) return null; diff --git a/src/modules/Elsa.Persistence.EFCore/Modules/Identity/ApplicationStore.cs b/src/modules/Elsa.Persistence.EFCore/Modules/Identity/ApplicationStore.cs index 48732d8ac..69dbaf2ab 100644 --- a/src/modules/Elsa.Persistence.EFCore/Modules/Identity/ApplicationStore.cs +++ b/src/modules/Elsa.Persistence.EFCore/Modules/Identity/ApplicationStore.cs @@ -34,8 +34,8 @@ public class EFCoreApplicationStore : IApplicationStore /// public async Task FindAsync(ApplicationFilter filter, CancellationToken cancellationToken = default) { - return await _applicationStore.FindAsync(query => Filter(query, filter), cancellationToken); + return await _applicationStore.FindAsync(query => Filter(query, filter), filter.TenantAgnostic, cancellationToken); } private static IQueryable Filter(IQueryable query, ApplicationFilter filter) => filter.Apply(query); -} \ No newline at end of file +} diff --git a/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs b/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs index d47e04d93..c343b3441 100644 --- a/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs +++ b/test/unit/Elsa.Identity.UnitTests/Services/DefaultSecretHasherTests.cs @@ -265,6 +265,34 @@ public class DefaultSecretHasherTests Assert.Equal(encodedLegacyHash, application.HashedApiKey); } + [Fact] + public async Task ValidateAsync_UsesTenantAgnosticLookupForApplications() + { + var apiKeyGenerator = new DefaultApiKeyGeneratorAndParser(); + var apiKey = apiKeyGenerator.Generate("client-1"); + var hasher = new DefaultSecretHasher(); + var hashedApiKey = hasher.HashSecret(apiKey); + var application = new Application + { + Id = "app-1", + ClientId = "client-1", + Name = "Client 1", + HashedApiKey = hashedApiKey.EncodeSecret(), + HashedApiKeySalt = hashedApiKey.EncodeSalt(), + HashedClientSecret = "", + HashedClientSecretSalt = "" + }; + var applicationProvider = new RecordingApplicationProvider(application); + var validator = new DefaultApplicationCredentialsValidator(apiKeyGenerator, applicationProvider, hasher); + + var validatedApplication = await validator.ValidateAsync(apiKey); + + Assert.Same(application, validatedApplication); + Assert.NotNull(applicationProvider.LastFilter); + Assert.True(applicationProvider.LastFilter!.TenantAgnostic); + Assert.Equal("client-1", applicationProvider.LastFilter.ClientId); + } + private static HashedSecret CreateLegacyHash(string secret) { var salt = RandomNumberGenerator.GetBytes(32); @@ -370,6 +398,19 @@ public class DefaultSecretHasherTests } } + private sealed class RecordingApplicationProvider(Application application) : IApplicationProvider + { + private readonly Application _application = application; + public ApplicationFilter? LastFilter { get; private set; } + + public Task FindAsync(ApplicationFilter filter, CancellationToken cancellationToken = default) + { + LastFilter = filter; + var application = filter.Apply(new[] { _application }.AsQueryable()).FirstOrDefault(); + return Task.FromResult(application); + } + } + private sealed class CultureScope : IDisposable { private readonly CultureInfo _currentCulture = CultureInfo.CurrentCulture;