# Step 17: S3/Storage Handler Implementation **Phase 4: Handler Plugins** **Estimated Complexity:** Medium **Dependencies:** Step 10 (Microservice Handler) --- ## Overview The S3/Storage handler routes file operations to object storage backends (S3, MinIO, Azure Blob, GCS). It handles presigned URL generation, multipart uploads, streaming downloads, and integrates with claim-based access control. --- ## Goals 1. Route file operations to appropriate storage backends 2. Generate presigned URLs for direct client uploads/downloads 3. Support multipart uploads for large files 4. Stream files without buffering in gateway 5. Enforce claim-based access control on storage operations --- ## Core Architecture ``` ┌────────────────────────────────────────────────────────────────┐ │ Storage Handler │ ├────────────────────────────────────────────────────────────────┤ │ │ │ HTTP Request │ │ │ │ │ ▼ │ │ ┌───────────────┐ ┌─────────────────────┐ │ │ │ Path Resolver │───►│ Bucket/Key Mapping │ │ │ └───────┬───────┘ └─────────────────────┘ │ │ │ │ │ ▼ │ │ ┌───────────────┐ ┌─────────────────────┐ │ │ │Access Control │───►│ Claim-Based Policy │ │ │ └───────┬───────┘ └─────────────────────┘ │ │ │ │ │ ▼ │ │ ┌───────────────────────────────────────────┐ │ │ │ Storage Backend │ │ │ │ ┌─────┐ ┌───────┐ ┌──────┐ ┌─────┐ │ │ │ │ │ S3 │ │ MinIO │ │Azure │ │ GCS │ │ │ │ │ └─────┘ └───────┘ └──────┘ └─────┘ │ │ │ └───────────────────────────────────────────┘ │ │ │ └────────────────────────────────────────────────────────────────┘ ``` --- ## Configuration ```csharp namespace StellaOps.Router.Handlers.Storage; public class StorageHandlerConfig { /// Path prefix for storage routes. public string PathPrefix { get; set; } = "/files"; /// Default storage backend. public string DefaultBackend { get; set; } = "s3"; /// Maximum upload size (bytes). public long MaxUploadSize { get; set; } = 5L * 1024 * 1024 * 1024; // 5GB /// Multipart threshold (bytes). public long MultipartThreshold { get; set; } = 100 * 1024 * 1024; // 100MB /// Presigned URL expiration. public TimeSpan PresignedUrlExpiration { get; set; } = TimeSpan.FromHours(1); /// Whether to use presigned URLs for uploads. public bool UsePresignedUploads { get; set; } = true; /// Whether to use presigned URLs for downloads. public bool UsePresignedDownloads { get; set; } = true; /// Storage backends configuration. public Dictionary Backends { get; set; } = new(); /// Bucket mappings (path pattern to bucket). public List BucketMappings { get; set; } = new(); } public class StorageBackendConfig { public string Type { get; set; } = "S3"; // S3, Azure, GCS public string Endpoint { get; set; } = ""; public string Region { get; set; } = "us-east-1"; public string AccessKey { get; set; } = ""; public string SecretKey { get; set; } = ""; public bool UsePathStyle { get; set; } = false; public bool UseSsl { get; set; } = true; } public class BucketMapping { public string PathPattern { get; set; } = ""; public string Bucket { get; set; } = ""; public string? KeyPrefix { get; set; } public string Backend { get; set; } = "default"; public StorageAccessPolicy Policy { get; set; } = new(); } public class StorageAccessPolicy { public bool RequireAuthentication { get; set; } = true; public List AllowedClaims { get; set; } = new(); public string? OwnerClaimPath { get; set; } public bool EnforceOwnership { get; set; } = false; } ``` --- ## Storage Handler Implementation ```csharp namespace StellaOps.Router.Handlers.Storage; public sealed class StorageHandler : IRouteHandler { public string HandlerType => "Storage"; public int Priority => 90; private readonly StorageHandlerConfig _config; private readonly IStorageBackendFactory _backendFactory; private readonly IAccessControlEvaluator _accessControl; private readonly ILogger _logger; public StorageHandler( IOptions config, IStorageBackendFactory backendFactory, IAccessControlEvaluator accessControl, ILogger logger) { _config = config.Value; _backendFactory = backendFactory; _accessControl = accessControl; _logger = logger; } public bool CanHandle(RouteMatchResult match) { return match.Handler == "Storage" || match.Route.Path.StartsWith(_config.PathPrefix, StringComparison.OrdinalIgnoreCase); } public async Task HandleAsync( HttpContext context, RouteMatchResult match, IReadOnlyDictionary claims, CancellationToken cancellationToken) { try { // Resolve storage location var location = ResolveLocation(context.Request.Path, context.Request.Query); // Check access var accessResult = _accessControl.Evaluate(location, claims, context.Request.Method); if (!accessResult.Allowed) { return new RouteHandlerResult { Handled = true, StatusCode = 403, Body = Encoding.UTF8.GetBytes(accessResult.Reason ?? "Access denied") }; } // Get backend var backend = _backendFactory.GetBackend(location.Backend); return context.Request.Method.ToUpper() switch { "GET" => await HandleGetAsync(context, backend, location, cancellationToken), "HEAD" => await HandleHeadAsync(context, backend, location, cancellationToken), "PUT" => await HandlePutAsync(context, backend, location, claims, cancellationToken), "POST" => await HandlePostAsync(context, backend, location, claims, cancellationToken), "DELETE" => await HandleDeleteAsync(context, backend, location, cancellationToken), _ => new RouteHandlerResult { Handled = true, StatusCode = 405 } }; } catch (StorageNotFoundException) { return new RouteHandlerResult { Handled = true, StatusCode = 404 }; } catch (Exception ex) { _logger.LogError(ex, "Storage operation error"); return new RouteHandlerResult { Handled = true, StatusCode = 500, Body = Encoding.UTF8.GetBytes("Storage operation failed") }; } } private StorageLocation ResolveLocation(PathString path, IQueryCollection query) { var relativePath = path.Value?.Substring(_config.PathPrefix.Length).TrimStart('/') ?? ""; foreach (var mapping in _config.BucketMappings) { if (IsMatch(relativePath, mapping.PathPattern)) { var key = ExtractKey(relativePath, mapping); return new StorageLocation { Backend = mapping.Backend, Bucket = mapping.Bucket, Key = key, Policy = mapping.Policy }; } } // Default: first segment is bucket, rest is key var segments = relativePath.Split('/', 2); return new StorageLocation { Backend = _config.DefaultBackend, Bucket = segments[0], Key = segments.Length > 1 ? segments[1] : "" }; } private bool IsMatch(string path, string pattern) { var regex = new Regex("^" + Regex.Escape(pattern).Replace("\\*", ".*") + "$"); return regex.IsMatch(path); } private string ExtractKey(string path, BucketMapping mapping) { var key = path; if (!string.IsNullOrEmpty(mapping.KeyPrefix)) { key = mapping.KeyPrefix.TrimEnd('/') + "/" + key; } return key; } private async Task HandleGetAsync( HttpContext context, IStorageBackend backend, StorageLocation location, CancellationToken cancellationToken) { // Check for presigned download if (_config.UsePresignedDownloads && !IsRangeRequest(context.Request)) { var presignedUrl = await backend.GetPresignedDownloadUrlAsync( location.Bucket, location.Key, _config.PresignedUrlExpiration, cancellationToken); return new RouteHandlerResult { Handled = true, StatusCode = 307, // Temporary Redirect Headers = new Dictionary { ["Location"] = presignedUrl, ["Cache-Control"] = "no-store" } }; } // Stream directly var metadata = await backend.GetObjectMetadataAsync(location.Bucket, location.Key, cancellationToken); var stream = await backend.GetObjectStreamAsync(location.Bucket, location.Key, cancellationToken); context.Response.StatusCode = 200; context.Response.ContentType = metadata.ContentType; context.Response.ContentLength = metadata.ContentLength; if (!string.IsNullOrEmpty(metadata.ETag)) { context.Response.Headers["ETag"] = metadata.ETag; } await stream.CopyToAsync(context.Response.Body, cancellationToken); return new RouteHandlerResult { Handled = true, StatusCode = 200 }; } private bool IsRangeRequest(HttpRequest request) { return request.Headers.ContainsKey("Range"); } private async Task HandleHeadAsync( HttpContext context, IStorageBackend backend, StorageLocation location, CancellationToken cancellationToken) { var metadata = await backend.GetObjectMetadataAsync(location.Bucket, location.Key, cancellationToken); return new RouteHandlerResult { Handled = true, StatusCode = 200, Headers = new Dictionary { ["Content-Type"] = metadata.ContentType, ["Content-Length"] = metadata.ContentLength.ToString(), ["ETag"] = metadata.ETag ?? "", ["Last-Modified"] = metadata.LastModified.ToString("R") } }; } private async Task HandlePutAsync( HttpContext context, IStorageBackend backend, StorageLocation location, IReadOnlyDictionary claims, CancellationToken cancellationToken) { var contentLength = context.Request.ContentLength ?? 0; // Validate size if (contentLength > _config.MaxUploadSize) { return new RouteHandlerResult { Handled = true, StatusCode = 413, Body = Encoding.UTF8.GetBytes($"File too large. Max size: {_config.MaxUploadSize}") }; } // Use presigned upload for large files if (_config.UsePresignedUploads && contentLength > _config.MultipartThreshold) { var uploadInfo = await backend.InitiateMultipartUploadAsync( location.Bucket, location.Key, context.Request.ContentType ?? "application/octet-stream", cancellationToken); return new RouteHandlerResult { Handled = true, StatusCode = 200, ContentType = "application/json", Body = JsonSerializer.SerializeToUtf8Bytes(new { uploadId = uploadInfo.UploadId, parts = uploadInfo.PresignedPartUrls }) }; } // Direct upload var contentType = context.Request.ContentType ?? "application/octet-stream"; var metadata = new Dictionary(); // Add owner metadata if enforced if (location.Policy?.EnforceOwnership == true && location.Policy.OwnerClaimPath != null) { if (claims.TryGetValue(location.Policy.OwnerClaimPath, out var owner)) { metadata["x-owner"] = owner; } } await backend.PutObjectAsync( location.Bucket, location.Key, context.Request.Body, contentLength, contentType, metadata, cancellationToken); return new RouteHandlerResult { Handled = true, StatusCode = 201, Headers = new Dictionary { ["Location"] = $"{_config.PathPrefix}/{location.Bucket}/{location.Key}" } }; } private async Task HandlePostAsync( HttpContext context, IStorageBackend backend, StorageLocation location, IReadOnlyDictionary claims, CancellationToken cancellationToken) { var action = context.Request.Query["action"].ToString(); return action switch { "presign" => await HandlePresignRequestAsync(context, backend, location, cancellationToken), "complete" => await HandleCompleteMultipartAsync(context, backend, location, cancellationToken), "abort" => await HandleAbortMultipartAsync(context, backend, location, cancellationToken), _ => await HandlePutAsync(context, backend, location, claims, cancellationToken) }; } private async Task HandlePresignRequestAsync( HttpContext context, IStorageBackend backend, StorageLocation location, CancellationToken cancellationToken) { var method = context.Request.Query["method"].ToString().ToUpper(); var expiration = _config.PresignedUrlExpiration; string presignedUrl; if (method == "PUT") { var contentType = context.Request.Query["contentType"].ToString(); presignedUrl = await backend.GetPresignedUploadUrlAsync( location.Bucket, location.Key, contentType, expiration, cancellationToken); } else { presignedUrl = await backend.GetPresignedDownloadUrlAsync( location.Bucket, location.Key, expiration, cancellationToken); } return new RouteHandlerResult { Handled = true, StatusCode = 200, ContentType = "application/json", Body = JsonSerializer.SerializeToUtf8Bytes(new { url = presignedUrl, expiresAt = DateTimeOffset.UtcNow.Add(expiration) }) }; } private async Task HandleCompleteMultipartAsync( HttpContext context, IStorageBackend backend, StorageLocation location, CancellationToken cancellationToken) { var body = await JsonSerializer.DeserializeAsync( context.Request.Body, cancellationToken: cancellationToken); if (body == null) { return new RouteHandlerResult { Handled = true, StatusCode = 400 }; } await backend.CompleteMultipartUploadAsync( location.Bucket, location.Key, body.UploadId, body.Parts, cancellationToken); return new RouteHandlerResult { Handled = true, StatusCode = 200 }; } private async Task HandleAbortMultipartAsync( HttpContext context, IStorageBackend backend, StorageLocation location, CancellationToken cancellationToken) { var uploadId = context.Request.Query["uploadId"].ToString(); await backend.AbortMultipartUploadAsync( location.Bucket, location.Key, uploadId, cancellationToken); return new RouteHandlerResult { Handled = true, StatusCode = 204 }; } private async Task HandleDeleteAsync( HttpContext context, IStorageBackend backend, StorageLocation location, CancellationToken cancellationToken) { await backend.DeleteObjectAsync(location.Bucket, location.Key, cancellationToken); return new RouteHandlerResult { Handled = true, StatusCode = 204 }; } } internal class CompleteMultipartRequest { public string UploadId { get; set; } = ""; public List Parts { get; set; } = new(); } internal class StorageLocation { public string Backend { get; set; } = ""; public string Bucket { get; set; } = ""; public string Key { get; set; } = ""; public StorageAccessPolicy? Policy { get; set; } } ``` --- ## Storage Backend Interface ```csharp namespace StellaOps.Router.Handlers.Storage; public interface IStorageBackend { Task GetObjectMetadataAsync( string bucket, string key, CancellationToken cancellationToken); Task GetObjectStreamAsync( string bucket, string key, CancellationToken cancellationToken); Task PutObjectAsync( string bucket, string key, Stream content, long contentLength, string contentType, Dictionary? metadata, CancellationToken cancellationToken); Task DeleteObjectAsync( string bucket, string key, CancellationToken cancellationToken); Task GetPresignedDownloadUrlAsync( string bucket, string key, TimeSpan expiration, CancellationToken cancellationToken); Task GetPresignedUploadUrlAsync( string bucket, string key, string contentType, TimeSpan expiration, CancellationToken cancellationToken); Task InitiateMultipartUploadAsync( string bucket, string key, string contentType, CancellationToken cancellationToken); Task CompleteMultipartUploadAsync( string bucket, string key, string uploadId, List parts, CancellationToken cancellationToken); Task AbortMultipartUploadAsync( string bucket, string key, string uploadId, CancellationToken cancellationToken); } public class ObjectMetadata { public string ContentType { get; set; } = "application/octet-stream"; public long ContentLength { get; set; } public string? ETag { get; set; } public DateTimeOffset LastModified { get; set; } public Dictionary CustomMetadata { get; set; } = new(); } public class MultipartUploadInfo { public string UploadId { get; set; } = ""; public List PresignedPartUrls { get; set; } = new(); } public class PresignedPartUrl { public int PartNumber { get; set; } public string Url { get; set; } = ""; } public class UploadPart { public int PartNumber { get; set; } public string ETag { get; set; } = ""; } ``` --- ## S3 Backend Implementation ```csharp namespace StellaOps.Router.Handlers.Storage; public sealed class S3StorageBackend : IStorageBackend { private readonly IAmazonS3 _client; private readonly ILogger _logger; public S3StorageBackend(IAmazonS3 client, ILogger logger) { _client = client; _logger = logger; } public async Task GetObjectMetadataAsync( string bucket, string key, CancellationToken cancellationToken) { var response = await _client.GetObjectMetadataAsync(bucket, key, cancellationToken); return new ObjectMetadata { ContentType = response.Headers.ContentType, ContentLength = response.ContentLength, ETag = response.ETag, LastModified = response.LastModified, CustomMetadata = response.Metadata.Keys .ToDictionary(k => k, k => response.Metadata[k]) }; } public async Task GetObjectStreamAsync( string bucket, string key, CancellationToken cancellationToken) { var response = await _client.GetObjectAsync(bucket, key, cancellationToken); return response.ResponseStream; } public async Task PutObjectAsync( string bucket, string key, Stream content, long contentLength, string contentType, Dictionary? metadata, CancellationToken cancellationToken) { var request = new PutObjectRequest { BucketName = bucket, Key = key, InputStream = content, ContentType = contentType }; if (metadata != null) { foreach (var (k, v) in metadata) { request.Metadata.Add(k, v); } } await _client.PutObjectAsync(request, cancellationToken); } public async Task DeleteObjectAsync( string bucket, string key, CancellationToken cancellationToken) { await _client.DeleteObjectAsync(bucket, key, cancellationToken); } public Task GetPresignedDownloadUrlAsync( string bucket, string key, TimeSpan expiration, CancellationToken cancellationToken) { var request = new GetPreSignedUrlRequest { BucketName = bucket, Key = key, Expires = DateTime.UtcNow.Add(expiration), Verb = HttpVerb.GET }; var url = _client.GetPreSignedURL(request); return Task.FromResult(url); } public Task GetPresignedUploadUrlAsync( string bucket, string key, string contentType, TimeSpan expiration, CancellationToken cancellationToken) { var request = new GetPreSignedUrlRequest { BucketName = bucket, Key = key, Expires = DateTime.UtcNow.Add(expiration), Verb = HttpVerb.PUT, ContentType = contentType }; var url = _client.GetPreSignedURL(request); return Task.FromResult(url); } public async Task InitiateMultipartUploadAsync( string bucket, string key, string contentType, CancellationToken cancellationToken) { var initResponse = await _client.InitiateMultipartUploadAsync( bucket, key, cancellationToken); // Generate presigned URLs for parts (assuming 100MB parts, 50 parts max) var partUrls = new List(); for (int i = 1; i <= 50; i++) { var url = _client.GetPreSignedURL(new GetPreSignedUrlRequest { BucketName = bucket, Key = key, Expires = DateTime.UtcNow.AddHours(24), Verb = HttpVerb.PUT, UploadId = initResponse.UploadId, PartNumber = i }); partUrls.Add(new PresignedPartUrl { PartNumber = i, Url = url }); } return new MultipartUploadInfo { UploadId = initResponse.UploadId, PresignedPartUrls = partUrls }; } public async Task CompleteMultipartUploadAsync( string bucket, string key, string uploadId, List parts, CancellationToken cancellationToken) { var request = new CompleteMultipartUploadRequest { BucketName = bucket, Key = key, UploadId = uploadId, PartETags = parts.Select(p => new PartETag(p.PartNumber, p.ETag)).ToList() }; await _client.CompleteMultipartUploadAsync(request, cancellationToken); } public async Task AbortMultipartUploadAsync( string bucket, string key, string uploadId, CancellationToken cancellationToken) { await _client.AbortMultipartUploadAsync(bucket, key, uploadId, cancellationToken); } } ``` --- ## Access Control Evaluator ```csharp namespace StellaOps.Router.Handlers.Storage; public interface IAccessControlEvaluator { AccessResult Evaluate( StorageLocation location, IReadOnlyDictionary claims, string httpMethod); } public class AccessResult { public bool Allowed { get; set; } public string? Reason { get; set; } } public sealed class ClaimBasedAccessControlEvaluator : IAccessControlEvaluator { public AccessResult Evaluate( StorageLocation location, IReadOnlyDictionary claims, string httpMethod) { var policy = location.Policy ?? new StorageAccessPolicy(); // Check authentication requirement if (policy.RequireAuthentication && !claims.Any()) { return new AccessResult { Allowed = false, Reason = "Authentication required" }; } // Check allowed claims if (policy.AllowedClaims.Any()) { var hasRequiredClaim = policy.AllowedClaims.Any(c => { var parts = c.Split('=', 2); if (parts.Length == 2) { return claims.TryGetValue(parts[0], out var value) && value == parts[1]; } return claims.ContainsKey(c); }); if (!hasRequiredClaim) { return new AccessResult { Allowed = false, Reason = "Required claim not present" }; } } // Check ownership for write operations if (policy.EnforceOwnership && IsWriteOperation(httpMethod)) { if (string.IsNullOrEmpty(policy.OwnerClaimPath)) { return new AccessResult { Allowed = false, Reason = "Owner claim path not configured" }; } if (!claims.ContainsKey(policy.OwnerClaimPath)) { return new AccessResult { Allowed = false, Reason = "Owner claim required" }; } } return new AccessResult { Allowed = true }; } private bool IsWriteOperation(string method) { return method.ToUpper() is "PUT" or "POST" or "DELETE" or "PATCH"; } } ``` --- ## YAML Configuration ```yaml Storage: PathPrefix: "/files" DefaultBackend: "s3" MaxUploadSize: 5368709120 # 5GB MultipartThreshold: 104857600 # 100MB PresignedUrlExpiration: "01:00:00" UsePresignedUploads: true UsePresignedDownloads: true Backends: s3: Type: "S3" Endpoint: "https://s3.amazonaws.com" Region: "us-east-1" AccessKey: "${AWS_ACCESS_KEY}" SecretKey: "${AWS_SECRET_KEY}" minio: Type: "S3" Endpoint: "https://minio.internal:9000" Region: "us-east-1" AccessKey: "${MINIO_ACCESS_KEY}" SecretKey: "${MINIO_SECRET_KEY}" UsePathStyle: true BucketMappings: - PathPattern: "uploads/*" Bucket: "user-uploads" KeyPrefix: "files/" Backend: "s3" Policy: RequireAuthentication: true EnforceOwnership: true OwnerClaimPath: "sub" - PathPattern: "public/*" Bucket: "public-assets" Backend: "s3" Policy: RequireAuthentication: false ``` --- ## Deliverables 1. `StellaOps.Router.Handlers.Storage/StorageHandler.cs` 2. `StellaOps.Router.Handlers.Storage/StorageHandlerConfig.cs` 3. `StellaOps.Router.Handlers.Storage/IStorageBackend.cs` 4. `StellaOps.Router.Handlers.Storage/S3StorageBackend.cs` 5. `StellaOps.Router.Handlers.Storage/IAccessControlEvaluator.cs` 6. `StellaOps.Router.Handlers.Storage/ClaimBasedAccessControlEvaluator.cs` 7. `StellaOps.Router.Handlers.Storage/StorageBackendFactory.cs` 8. Presigned URL generation tests 9. Multipart upload tests 10. Access control tests --- ## Next Step Proceed to [Step 18: Reverse Proxy Handler Implementation](18-Step.md) to implement direct reverse proxy routing.