Merge pull request #7705 from elsa-workflows/codex/7579-tenant-agnostic-api-key

fix: use tenant-agnostic application lookup for API keys
This commit is contained in:
Sipke Schoorstra 2026-06-17 20:16:22 +02:00 committed by GitHub
commit 8e20386a28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 55 additions and 7 deletions

View file

@ -15,15 +15,17 @@ public static class ApplicationProviderExtensions
/// </summary>
/// <param name="applicationProvider">The user provider.</param>
/// <param name="clientId">The client ID to search by.</param>
/// <param name="tenantAgnostic">Whether to bypass tenant scoping when resolving the application.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The application with the specified client ID.</returns>
public static async Task<Application?> FindByClientIdAsync(this IApplicationProvider applicationProvider, string clientId, CancellationToken cancellationToken = default)
public static async Task<Application?> 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);
}
}
}

View file

@ -21,6 +21,11 @@ public class ApplicationFilter
/// Gets or sets the application name to filter for.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to ignore tenant scoping when querying the application store.
/// </summary>
public bool TenantAgnostic { get; set; }
/// <summary>
/// Applies the filter to the specified queryable.
@ -36,4 +41,4 @@ public class ApplicationFilter
return queryable;
}
}
}

View file

@ -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;

View file

@ -34,8 +34,8 @@ public class EFCoreApplicationStore : IApplicationStore
/// <inheritdoc />
public async Task<Application?> 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<Application> Filter(IQueryable<Application> query, ApplicationFilter filter) => filter.Apply(query);
}
}

View file

@ -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<Application?> 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;