Skip to content
Merged
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<PackageVersion Include="Aspire.Hosting" Version="13.0.0" />
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.0.0" />
<PackageVersion Include="AWSSDK.Core" Version="4.0.3.3" />
<PackageVersion Include="AWSSDK.DynamoDBv2" Version="4.0.5" />
<PackageVersion Include="AWSSDK.SQS" Version="4.0.2.5" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.7.14" />
<PackageVersion Include="Elastic.OpenTelemetry" Version="1.1.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,10 @@ public static class TelemetryConstants
/// Used to trace frontend telemetry proxying.
/// </summary>
public const string OtlpProxySourceName = "Elastic.Documentation.Api.OtlpProxy";

/// <summary>
/// ActivitySource name for distributed cache operations.
/// Used to trace cache hits, misses, and performance.
/// </summary>
public const string CacheSourceName = "Elastic.Documentation.Api.Cache";
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

namespace Elastic.Documentation.Api.Infrastructure.Aws;

/// <summary>
/// Abstraction for retrieving configuration parameters.
/// Infrastructure concern: Used by other Infrastructure adapters to get configuration.
/// </summary>
public interface IParameterProvider
{
Task<string> GetParam(string name, bool withDecryption = true, Cancel ctx = default);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Security.Cryptography;
using System.Text;

namespace Elastic.Documentation.Api.Infrastructure.Caching;

/// <summary>
/// Represents a cache key with automatic hashing of sensitive identifiers.
/// Prevents exposing sensitive data in cache keys (CodeQL security requirement).
/// </summary>
public sealed class CacheKey
{
/// <summary>
/// Gets the hashed key string for use in cache operations.
/// </summary>
public string Value { get; }

private CacheKey(string category, string identifier)
{
// Hash the identifier to prevent exposing sensitive data (CodeQL security requirement)
var bytes = Encoding.UTF8.GetBytes(identifier);
var hash = SHA256.HashData(bytes);
var hashBase64 = Convert.ToBase64String(hash);
// Use base64url encoding for cache key (URL-safe)
var hashBase64Url = hashBase64.Replace('+', '-').Replace('/', '_').TrimEnd('=');
Value = $"{category}:{hashBase64Url}";
}

/// <summary>
/// Creates a cache key from a category and identifier.
/// The identifier is automatically hashed to prevent exposing sensitive data.
/// </summary>
/// <param name="category">Cache category (e.g., "idtoken", "search")</param>
/// <param name="identifier">Identifier that may contain sensitive data (will be hashed)</param>
/// <returns>A CacheKey instance with the hashed key</returns>
public static CacheKey Create(string category, string identifier) => new(category, identifier);

/// <summary>
/// Implicit conversion to string for convenience.
/// </summary>
public static implicit operator string(CacheKey key) => key.Value;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Diagnostics;
using System.Globalization;
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.Model;
using Elastic.Documentation.Api.Core;
using Microsoft.Extensions.Logging;

namespace Elastic.Documentation.Api.Infrastructure.Caching;

/// <summary>
/// DynamoDB implementation of <see cref="IDistributedCache"/> for Lambda environments.
/// Provides distributed caching across all Lambda containers using DynamoDB as backing store.
/// Clean Code: Constructor injection (Dependency Inversion), small focused methods.
/// </summary>
public sealed class DynamoDbDistributedCache(IAmazonDynamoDB dynamoDb, string tableName, ILogger<DynamoDbDistributedCache> logger) : IDistributedCache
{
private static readonly ActivitySource ActivitySource = new(TelemetryConstants.CacheSourceName);

private readonly IAmazonDynamoDB _dynamoDb = dynamoDb;
private readonly string _tableName = tableName;
private readonly ILogger<DynamoDbDistributedCache> _logger = logger;

// DynamoDB attribute names
private const string AttributeCacheKey = "CacheKey";
private const string AttributeValue = "Value";
private const string AttributeTtl = "TTL";

public async Task<string?> GetAsync(CacheKey key, Cancel ct = default)
{
var hashedKey = key.Value;
using var activity = ActivitySource.StartActivity("get cache", ActivityKind.Client);
_ = (activity?.SetTag("cache.key", hashedKey));
_ = (activity?.SetTag("cache.table", _tableName));
_ = (activity?.SetTag("cache.backend", "dynamodb"));

try
{
var response = await _dynamoDb.GetItemAsync(new GetItemRequest
{
TableName = _tableName,
Key = new Dictionary<string, AttributeValue>
{
[AttributeCacheKey] = new AttributeValue { S = hashedKey }
}
}, ct);

if (!response.IsItemSet)
{
_ = (activity?.SetTag("cache.hit", false));
_logger.LogDebug("Cache miss for key: {CacheKey}", hashedKey);
return null;
}

// DynamoDB TTL handles expiration automatically
// Items may still be returned briefly after expiration until DynamoDB deletes them
var value = response.Item.TryGetValue(AttributeValue, out var valueAttr)
? valueAttr.S
: null;

_ = (activity?.SetTag("cache.hit", value != null));
if (value != null)
{
_logger.LogDebug("Cache hit for key: {CacheKey}", hashedKey);
}

return value;
}
catch (ResourceNotFoundException ex)
{
// Table doesn't exist - return null gracefully
// Infrastructure should create table, but don't fail hard in dev
_ = (activity?.SetTag("cache.error", "table_not_found"));
_logger.LogWarning(ex, "DynamoDB table {TableName} not found. Cache operations will fail gracefully.", _tableName);
return null;
}
catch (ProvisionedThroughputExceededException ex)
{
_ = (activity?.SetTag("cache.error", "provisioned_throughput_exceeded"));
_logger.LogWarning(ex, "Provisioned throughput exceeded for DynamoDB cache table {TableName}.", _tableName);
return null;
}
catch (InternalServerErrorException ex)
{
_ = (activity?.SetTag("cache.error", "internal_server_error"));
_logger.LogError(ex, "Internal server error retrieving cache key {CacheKey} from DynamoDB", hashedKey);
return null;
}
catch (Exception ex) when (ex is not OperationCanceledException and not TaskCanceledException)
{
_ = (activity?.SetStatus(ActivityStatusCode.Error, ex.Message));
_logger.LogError(ex, "Error retrieving cache key {CacheKey} from DynamoDB", hashedKey);
return null; // Fail gracefully
}
// Allow cancellation exceptions to propagate to respect request lifetimes
}

public async Task SetAsync(CacheKey key, string value, TimeSpan ttl, Cancel ct = default)
{
var hashedKey = key.Value;
using var activity = ActivitySource.StartActivity("set cache", ActivityKind.Client);
_ = (activity?.SetTag("cache.key", hashedKey));
_ = (activity?.SetTag("cache.table", _tableName));
_ = (activity?.SetTag("cache.backend", "dynamodb"));
_ = (activity?.SetTag("cache.ttl", ttl.TotalSeconds));

try
{
var expiresAt = DateTimeOffset.UtcNow.Add(ttl);
var ttlTimestamp = expiresAt.ToUnixTimeSeconds();

_ = await _dynamoDb.PutItemAsync(new PutItemRequest
{
TableName = _tableName,
Item = new Dictionary<string, AttributeValue>
{
[AttributeCacheKey] = new AttributeValue { S = hashedKey },
[AttributeValue] = new AttributeValue { S = value },
[AttributeTtl] = new AttributeValue { N = ttlTimestamp.ToString(CultureInfo.InvariantCulture) }
}
}, ct);

_logger.LogDebug("Cache set for key: {CacheKey}, TTL: {TTL}s", hashedKey, ttl.TotalSeconds);
}
catch (ResourceNotFoundException ex)
{
// Table doesn't exist - fail silently in dev, log in production
// Infrastructure should create table before deployment
_ = (activity?.SetTag("cache.error", "table_not_found"));
_logger.LogWarning(ex, "DynamoDB table {TableName} not found. Unable to cache key {CacheKey}.", _tableName, hashedKey);
}
catch (ProvisionedThroughputExceededException ex)
{
_ = (activity?.SetTag("cache.error", "provisioned_throughput_exceeded"));
_logger.LogWarning(ex, "Provisioned throughput exceeded for DynamoDB cache table {TableName}. Unable to cache key {CacheKey}.", _tableName, hashedKey);
}
catch (InternalServerErrorException ex)
{
_ = (activity?.SetTag("cache.error", "internal_server_error"));
_logger.LogError(ex, "Internal server error setting cache key {CacheKey} in DynamoDB", hashedKey);
}
catch (Exception ex) when (ex is not OperationCanceledException and not TaskCanceledException)
{
// Allow cancellation exceptions to propagate to respect request lifetimes
_ = activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Error setting cache key {CacheKey} in DynamoDB", hashedKey);
// Fail gracefully - don't throw
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

namespace Elastic.Documentation.Api.Infrastructure.Caching;

/// <summary>
/// Abstraction for distributed caching across Lambda invocations.
/// Infrastructure concern: Used by other Infrastructure adapters for caching.
/// </summary>
/// <remarks>
/// <para>
/// Cache keys should be created using <see cref="CacheKey.Create"/> to automatically hash
/// sensitive identifiers and prevent exposing sensitive data in cache keys (CodeQL security requirement).
/// </para>
/// <para>
/// Key format: {category}:{hashed-identifier} (e.g., "idtoken:{hash}" where hash is SHA256 of the identifier)
/// </para>
/// </remarks>
public interface IDistributedCache
{
/// <summary>
/// Retrieves a cached value by key.
/// </summary>
/// <param name="key">Cache key created using <see cref="CacheKey.Create"/> (format: {category}:{hashed-identifier})</param>
/// <param name="ct">Cancellation token</param>
/// <returns>Cached value as string, or null if not found or expired</returns>
Task<string?> GetAsync(CacheKey key, Cancel ct = default);

/// <summary>
/// Stores a value in the cache with a time-to-live.
/// </summary>
/// <param name="key">Cache key created using <see cref="CacheKey.Create"/> (format: {category}:{hashed-identifier})</param>
/// <param name="value">Value to cache (typically JSON-serialized data)</param>
/// <param name="ttl">Time-to-live duration</param>
/// <param name="ct">Cancellation token</param>
Task SetAsync(CacheKey key, string value, TimeSpan ttl, Cancel ct = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Collections.Concurrent;

namespace Elastic.Documentation.Api.Infrastructure.Caching;

/// <summary>
/// In-memory implementation of <see cref="IDistributedCache"/> for local development.
/// Uses ConcurrentDictionary for thread-safe storage with TTL-based expiration.
/// Clean Code: Sealed class (not meant for inheritance), single responsibility.
/// </summary>
public sealed class InMemoryDistributedCache : IDistributedCache
{
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();

/// <summary>
/// Immutable cache entry with value and expiration timestamp.
/// Clean Code: Record type ensures immutability.
/// </summary>
private sealed record CacheEntry(string Value, DateTimeOffset ExpiresAt);

public Task<string?> GetAsync(CacheKey key, Cancel ct = default)
{
var hashedKey = key.Value;
if (_cache.TryGetValue(hashedKey, out var entry))
{
if (IsExpired(entry))
{
// Remove expired entry
_ = _cache.TryRemove(hashedKey, out _);
return Task.FromResult<string?>(null);
}

return Task.FromResult<string?>(entry.Value);
}

return Task.FromResult<string?>(null);
}

public Task SetAsync(CacheKey key, string value, TimeSpan ttl, Cancel ct = default)
{
var hashedKey = key.Value;
var expiresAt = DateTimeOffset.UtcNow.Add(ttl);
var entry = new CacheEntry(value, expiresAt);
_ = _cache.AddOrUpdate(hashedKey, entry, (_, _) => entry);
return Task.CompletedTask;
}

/// <summary>
/// Checks if a cache entry has expired.
/// Clean Code: Single-purpose helper method with intention-revealing name.
/// </summary>
private static bool IsExpired(CacheEntry entry) =>
entry.ExpiresAt <= DateTimeOffset.UtcNow;
}
Loading
Loading