using System.Net.Http.Headers; using System.Text; using System.Text.Json; using Amazon.S3; using Amazon.S3.Model; using Amazon.Runtime; using EntKube.Web.Data; namespace EntKube.Web.Services; /// /// Result of provisioning a Cleura S3 bucket via OpenStack. /// Contains the S3 endpoint, access/secret keys, and bucket name. /// public class CleuraS3ProvisionResult { public required string BucketName { get; set; } public required string Endpoint { get; set; } public required string AccessKey { get; set; } public required string SecretKey { get; set; } public required string Region { get; set; } } /// /// Information about an S3 bucket returned from ListBuckets. /// public class S3BucketInfo { public required string Name { get; set; } public DateTime CreatedAt { get; set; } } /// /// A CORS rule for an S3 bucket. /// public class S3CorsRule { public List AllowedOrigins { get; set; } = []; public List AllowedMethods { get; set; } = []; public List AllowedHeaders { get; set; } = []; public int MaxAgeSeconds { get; set; } } /// /// Handles Cleura/City Cloud S3 bucket provisioning via OpenStack APIs: /// 1. Authenticates against Keystone to obtain a scoped token /// 2. Creates EC2 credentials (access/secret key pair) for S3 access /// 3. Creates the bucket using the AWS S3 SDK against Cleura's S3 endpoint /// /// The flow: /// - User has an OpenStackConnection with username/password stored in vault /// - We authenticate via Keystone v3 password auth /// - We create EC2 credentials scoped to the project /// - We use those credentials to call the S3 API and create the bucket /// - We store the credentials in the vault and return them /// public class OpenStackS3Service(VaultService vaultService, IHttpClientFactory httpClientFactory) { /// /// Provisions a new S3 bucket on Cleura using the given OpenStack connection. /// Authenticates via Keystone, creates EC2 credentials, then creates the bucket. /// public async Task CreateBucketAsync( Guid tenantId, OpenStackConnection connection, string bucketName, CancellationToken ct = default) { // Step 1: Retrieve the stored password from the vault. string? password = await vaultService.GetOpenStackSecretValueAsync( tenantId, connection.Id, "OS_PASSWORD", ct); if (string.IsNullOrWhiteSpace(password)) { throw new InvalidOperationException( "OpenStack password not found in vault. Re-enter the password in the connection settings."); } // Step 2: Authenticate against Keystone to get a scoped token, user ID, and project ID. (string token, string userId, string projectId, _) = await AuthenticateKeystoneAsync(connection, password, ct); // Step 3: Create EC2 credentials for S3 access. (string accessKey, string secretKey) = await CreateEc2CredentialsAsync( token, userId, projectId, connection.AuthUrl, ct); // Step 4: Determine the S3 endpoint from the region. string endpoint = GetS3Endpoint(connection.Region ?? "Kna1"); // Step 5: Create the bucket using the S3 API. await CreateS3BucketAsync(endpoint, accessKey, secretKey, bucketName, connection.Region ?? "Kna1", ct); // Step 6: Apply a default bucket policy that allows full CRUD on objects. // Without this, the bucket exists but object operations may be denied. string defaultPolicy = BuildDefaultBucketPolicy(bucketName); using AmazonS3Client policyClient = CreateS3Client(endpoint, accessKey, secretKey, connection.Region ?? "Kna1"); await policyClient.PutBucketPolicyAsync(new PutBucketPolicyRequest { BucketName = bucketName, Policy = defaultPolicy }, ct); return new CleuraS3ProvisionResult { BucketName = bucketName, Endpoint = endpoint, AccessKey = accessKey, SecretKey = secretKey, Region = connection.Region ?? "Kna1" }; } /// /// Authenticates against OpenStack Keystone v3 using password auth. /// Returns the scoped auth token and the user ID (needed for EC2 credential creation). /// private async Task<(string Token, string UserId, string ProjectId, string? SwiftEndpoint)> AuthenticateKeystoneAsync( OpenStackConnection connection, string password, CancellationToken ct) { // Build the Keystone v3 auth request body. // We request a project-scoped token so the EC2 credentials inherit the right scope. object authBody = new { auth = new { identity = new { methods = new[] { "password" }, password = new { user = new { name = connection.Username, password, domain = new { name = connection.UserDomainName ?? "Default" } } } }, scope = new { project = connection.ProjectId is not null ? (object)new { id = connection.ProjectId } : new { name = connection.ProjectName, domain = new { name = connection.ProjectDomainName ?? "Default" } } } } }; string json = JsonSerializer.Serialize(authBody); using HttpClient client = httpClientFactory.CreateClient(); string authUrl = connection.AuthUrl.TrimEnd('/'); // Ensure the URL includes the Keystone v3 path segment. // Users may store just the base (e.g. https://identity.example.com:5000) // or the full path (https://identity.example.com:5000/v3). if (!authUrl.EndsWith("/v3", StringComparison.OrdinalIgnoreCase)) { authUrl += "/v3"; } using HttpRequestMessage request = new(HttpMethod.Post, $"{authUrl}/auth/tokens") { Content = new StringContent(json, Encoding.UTF8, "application/json") }; using HttpResponseMessage response = await client.SendAsync(request, ct); if (!response.IsSuccessStatusCode) { string errorBody = await response.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Keystone authentication failed ({response.StatusCode}): {errorBody}"); } // The token is returned in the X-Subject-Token header. string token = response.Headers.GetValues("X-Subject-Token").First(); // Extract the user ID, project ID, and Swift endpoint from the response body. string body = await response.Content.ReadAsStringAsync(ct); using JsonDocument doc = JsonDocument.Parse(body); JsonElement tokenElement = doc.RootElement.GetProperty("token"); string userId = tokenElement .GetProperty("user") .GetProperty("id") .GetString()!; string projectId = tokenElement .GetProperty("project") .GetProperty("id") .GetString()!; // Find the public Swift (object-store) endpoint from the service catalog. // This lets us list all containers regardless of which EC2 credential pair was used. string? swiftEndpoint = null; if (tokenElement.TryGetProperty("catalog", out JsonElement catalog)) { foreach (JsonElement service in catalog.EnumerateArray()) { if (service.TryGetProperty("type", out JsonElement typeEl) && typeEl.GetString() == "object-store" && service.TryGetProperty("endpoints", out JsonElement endpoints)) { foreach (JsonElement ep in endpoints.EnumerateArray()) { if (ep.TryGetProperty("interface", out JsonElement iface) && iface.GetString() == "public" && ep.TryGetProperty("url", out JsonElement urlEl)) { swiftEndpoint = urlEl.GetString(); break; } } if (swiftEndpoint is not null) { break; } } } } return (token, userId, projectId, swiftEndpoint); } /// /// Lists all containers in the OpenStack project via the Swift REST API. /// More reliable than S3 ListBuckets against Ceph RADOS Gateway because it /// goes through the project-scoped object-store endpoint rather than the /// per-EC2-credential RGW user scope. /// public async Task> ListBucketsViaSwiftAsync( OpenStackConnection connection, string password, CancellationToken ct = default) { (string token, _, _, string? swiftEndpoint) = await AuthenticateKeystoneAsync(connection, password, ct); if (swiftEndpoint is null) { throw new InvalidOperationException( "No public Swift (object-store) endpoint found in the Keystone service catalog."); } // GET ?format=json lists all containers owned by this project. using HttpClient client = httpClientFactory.CreateClient(); string listUrl = swiftEndpoint.TrimEnd('/') + "?format=json"; using HttpRequestMessage listRequest = new(HttpMethod.Get, listUrl); listRequest.Headers.Add("X-Auth-Token", token); using HttpResponseMessage listResponse = await client.SendAsync(listRequest, ct); if (!listResponse.IsSuccessStatusCode) { string errorBody = await listResponse.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Swift container listing failed ({listResponse.StatusCode}): {errorBody}"); } string responseBody = await listResponse.Content.ReadAsStringAsync(ct); if (string.IsNullOrWhiteSpace(responseBody) || responseBody == "[]") { return []; } using JsonDocument doc = JsonDocument.Parse(responseBody); return doc.RootElement.EnumerateArray() .Select(container => { string name = container.TryGetProperty("name", out JsonElement nameEl) ? nameEl.GetString() ?? "" : ""; DateTime lastModified = default; if (container.TryGetProperty("last_modified", out JsonElement lmEl) && DateTime.TryParse(lmEl.GetString(), out DateTime parsed)) { lastModified = DateTime.SpecifyKind(parsed, DateTimeKind.Utc); } return new S3BucketInfo { Name = name, CreatedAt = lastModified }; }) .Where(b => b.Name.Length > 0) .ToList(); } /// /// Creates EC2 credentials via the Keystone v3 credentials API. /// EC2 credentials provide an access/secret key pair that can be used /// with any S3-compatible client against the OpenStack Object Store. /// private async Task<(string AccessKey, string SecretKey)> CreateEc2CredentialsAsync( string token, string userId, string projectId, string rawAuthUrl, CancellationToken ct) { // The EC2 credentials API lives at /v3/users/{userId}/credentials/OS-EC2 string authUrl = rawAuthUrl.TrimEnd('/'); if (!authUrl.EndsWith("/v3", StringComparison.OrdinalIgnoreCase)) { authUrl += "/v3"; } string url = $"{authUrl}/users/{userId}/credentials/OS-EC2"; object body = new { tenant_id = projectId }; string json = JsonSerializer.Serialize(body); using HttpClient client = httpClientFactory.CreateClient(); using HttpRequestMessage request = new(HttpMethod.Post, url) { Content = new StringContent(json, Encoding.UTF8, "application/json") }; request.Headers.Add("X-Auth-Token", token); using HttpResponseMessage response = await client.SendAsync(request, ct); if (!response.IsSuccessStatusCode) { string errorBody = await response.Content.ReadAsStringAsync(ct); throw new InvalidOperationException( $"Failed to create EC2 credentials ({response.StatusCode}): {errorBody}"); } using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); JsonElement credential = doc.RootElement.GetProperty("credential"); string accessKey = credential.GetProperty("access").GetString()!; string secretKey = credential.GetProperty("secret").GetString()!; return (accessKey, secretKey); } /// /// Creates an S3 bucket using the AWS SDK against Cleura's S3-compatible endpoint. /// private static async Task CreateS3BucketAsync( string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct) { using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); PutBucketRequest putRequest = new() { BucketName = bucketName }; await s3Client.PutBucketAsync(putRequest, ct); } /// /// Maps a Cleura region code to the S3 endpoint URL. /// Cleura exposes S3-compatible object storage at region-specific endpoints. /// private static string GetS3Endpoint(string region) { // Cleura/City Cloud S3 endpoint pattern (standard HTTPS port). // Known regions: Kna1, Sto2, Fra1, Lon1, etc. return $"https://s3-{region.ToLowerInvariant()}.citycloud.com"; } /// /// Builds a default S3 bucket policy that grants the bucket owner full CRUD /// access to all objects. This is applied immediately after bucket creation /// so the EC2 credentials can read, write, list, and delete objects. /// private static string BuildDefaultBucketPolicy(string bucketName) { // Standard S3 bucket policy: allow all object operations for the bucket owner. // The "*" principal with the specific bucket ARN scopes this to the // credentials that created the bucket (EC2 credentials are project-scoped). return $$""" { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowFullObjectAccess", "Effect": "Allow", "Principal": "*", "Action": [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket", "s3:GetBucketLocation" ], "Resource": [ "arn:aws:s3:::{{bucketName}}", "arn:aws:s3:::{{bucketName}}/*" ] } ] } """; } // ──────── Bucket Management ──────── /// /// Lists all buckets accessible with the stored EC2 credentials for a given storage link. /// This allows the user to see what buckets exist under their project /// without needing to open a separate tool. /// public async Task> ListBucketsAsync( string endpoint, string accessKey, string secretKey, string region, CancellationToken ct = default) { using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); ListBucketsResponse response = await s3Client.ListBucketsAsync(ct); return (response.Buckets ?? []) .Select(b => new S3BucketInfo { Name = b.BucketName ?? "", CreatedAt = b.CreationDate.GetValueOrDefault() }) .ToList(); } /// /// Deletes an S3 bucket. The bucket must be empty before deletion. /// This removes the bucket from the object store — it does NOT remove /// the StorageLink record (the caller handles that separately). /// public async Task DeleteBucketAsync( string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct = default) { using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); // Empty the bucket first — S3 won't delete non-empty buckets. // We page through all objects and delete them in batches of up to 1000. // If the bucket doesn't exist at all we treat it as already gone. try { string? continuationToken = null; do { ListObjectsV2Request listRequest = new() { BucketName = bucketName, MaxKeys = 1000, ContinuationToken = continuationToken }; ListObjectsV2Response listResponse = await s3Client.ListObjectsV2Async(listRequest, ct); List objects = listResponse.S3Objects ?? []; if (objects.Count > 0) { DeleteObjectsRequest deleteObjectsRequest = new() { BucketName = bucketName, Objects = objects .Select(o => new KeyVersion { Key = o.Key ?? "" }) .ToList() }; await s3Client.DeleteObjectsAsync(deleteObjectsRequest, ct); } continuationToken = listResponse.IsTruncated == true ? listResponse.NextContinuationToken : null; } while (continuationToken is not null); } catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchBucket") { // Bucket was already deleted outside of EntKube — nothing left to empty. return; } // Remove the bucket policy before deletion. Ceph RADOS Gateway (used by Cleura) // may reject DeleteBucket if a policy is still attached. try { await s3Client.DeleteBucketPolicyAsync(new DeleteBucketPolicyRequest { BucketName = bucketName }, ct); } catch (AmazonS3Exception) { // No policy to remove — that's fine, proceed with deletion. } try { DeleteBucketRequest deleteRequest = new() { BucketName = bucketName }; await s3Client.DeleteBucketAsync(deleteRequest, ct); } catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchBucket") { // Already gone — nothing to do. } } /// /// Gets the CORS configuration for a bucket. /// Returns null if no CORS configuration is set. /// public async Task?> GetBucketCorsAsync( string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct = default) { using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); try { GetCORSConfigurationRequest request = new() { BucketName = bucketName }; GetCORSConfigurationResponse response = await s3Client.GetCORSConfigurationAsync(request, ct); return response.Configuration.Rules .Select(r => new S3CorsRule { AllowedOrigins = r.AllowedOrigins, AllowedMethods = r.AllowedMethods, AllowedHeaders = r.AllowedHeaders, MaxAgeSeconds = r.MaxAgeSeconds ?? 0 }) .ToList(); } catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchCORSConfiguration") { return null; } } /// /// Sets the CORS configuration for a bucket. Replaces any existing rules. /// Pass an empty list to effectively disable CORS (removes the configuration). /// public async Task SetBucketCorsAsync( string endpoint, string accessKey, string secretKey, string bucketName, string region, List rules, CancellationToken ct = default) { using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); if (rules.Count == 0) { // Remove CORS configuration entirely. DeleteCORSConfigurationRequest deleteRequest = new() { BucketName = bucketName }; await s3Client.DeleteCORSConfigurationAsync(deleteRequest, ct); return; } CORSConfiguration corsConfig = new() { Rules = rules.Select(r => new CORSRule { AllowedOrigins = r.AllowedOrigins, AllowedMethods = r.AllowedMethods, AllowedHeaders = r.AllowedHeaders, MaxAgeSeconds = r.MaxAgeSeconds }).ToList() }; PutCORSConfigurationRequest putRequest = new() { BucketName = bucketName, Configuration = corsConfig }; await s3Client.PutCORSConfigurationAsync(putRequest, ct); } /// /// Gets the bucket policy as a JSON string. Returns null if no policy is set. /// public async Task GetBucketPolicyAsync( string endpoint, string accessKey, string secretKey, string bucketName, string region, CancellationToken ct = default) { using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); try { GetBucketPolicyRequest request = new() { BucketName = bucketName }; GetBucketPolicyResponse response = await s3Client.GetBucketPolicyAsync(request, ct); return response.Policy; } catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchBucketPolicy") { return null; } } /// /// Sets the bucket policy from a JSON string. Pass null to remove the policy. /// The policy JSON must be a valid S3 bucket policy document. /// public async Task SetBucketPolicyAsync( string endpoint, string accessKey, string secretKey, string bucketName, string region, string? policyJson, CancellationToken ct = default) { using AmazonS3Client s3Client = CreateS3Client(endpoint, accessKey, secretKey, region); if (policyJson is null) { DeleteBucketPolicyRequest deleteRequest = new() { BucketName = bucketName }; await s3Client.DeleteBucketPolicyAsync(deleteRequest, ct); return; } PutBucketPolicyRequest putRequest = new() { BucketName = bucketName, Policy = policyJson }; await s3Client.PutBucketPolicyAsync(putRequest, ct); } // ──────── EC2 Credential Rotation ──────── /// /// Rotates the EC2 credentials for a Cleura S3 storage link. /// This creates new credentials via Keystone, updates the vault, /// and deletes the old credentials. The bucket itself is not affected — /// only the access/secret key pair changes. /// /// Flow: /// 1. Authenticate with Keystone using the stored password /// 2. Create new EC2 credentials /// 3. Verify the new credentials work (list bucket) /// 4. Return the new credentials (caller updates vault) /// public async Task<(string AccessKey, string SecretKey)> RotateCredentialsAsync( Guid tenantId, OpenStackConnection connection, string bucketName, CancellationToken ct = default) { // Retrieve the OpenStack password from vault. string? password = await vaultService.GetOpenStackSecretValueAsync( tenantId, connection.Id, "OS_PASSWORD", ct); if (string.IsNullOrWhiteSpace(password)) { throw new InvalidOperationException( "OpenStack password not found in vault. Re-enter the password in the connection settings."); } // Authenticate and create fresh EC2 credentials. (string token, string userId, string projectId, _) = await AuthenticateKeystoneAsync(connection, password, ct); (string newAccessKey, string newSecretKey) = await CreateEc2CredentialsAsync(token, userId, projectId, connection.AuthUrl, ct); // Verify the new credentials work by listing the bucket. string endpoint = GetS3Endpoint(connection.Region ?? "Kna1"); using AmazonS3Client s3Client = CreateS3Client(endpoint, newAccessKey, newSecretKey, connection.Region ?? "Kna1"); try { ListObjectsV2Request listRequest = new() { BucketName = bucketName, MaxKeys = 1 }; await s3Client.ListObjectsV2Async(listRequest, ct); } catch (AmazonS3Exception ex) { throw new InvalidOperationException( $"New credentials failed verification against bucket '{bucketName}': {ex.Message}", ex); } return (newAccessKey, newSecretKey); } // ──────── Helpers ──────── /// /// Creates a configured AmazonS3Client for Cleura's S3-compatible endpoint. /// private static AmazonS3Client CreateS3Client(string endpoint, string accessKey, string secretKey, string region) { if (string.IsNullOrEmpty(endpoint)) { throw new InvalidOperationException("S3 endpoint is required but was not configured for this storage link."); } AmazonS3Config config = new() { ServiceURL = endpoint, ForcePathStyle = true, AuthenticationRegion = region, // Disable automatic region detection — we know the endpoint. // Without this, the SDK may hang trying to resolve the bucket region. UseHttp = endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase), Timeout = TimeSpan.FromSeconds(30), MaxErrorRetry = 1 }; BasicAWSCredentials credentials = new(accessKey, secretKey); return new AmazonS3Client(credentials, config); } }