Skip to content

Commit

Permalink
Introduce delegation SAS (#10159)
Browse files Browse the repository at this point in the history
  • Loading branch information
erdembayar authored Aug 30, 2024
1 parent 5c62798 commit 4c6725a
Show file tree
Hide file tree
Showing 10 changed files with 335 additions and 54 deletions.
1 change: 1 addition & 0 deletions src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

<ItemGroup>
<PackageReference Include="Autofac.Extensions.DependencyInjection" />
<PackageReference Include="Azure.Data.Tables" />
<PackageReference Include="Dapper.StrongName" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
Expand Down
69 changes: 69 additions & 0 deletions src/NuGet.Jobs.Common/StorageAccountExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using System;
using Autofac;
using Autofac.Builder;
using Azure.Data.Tables;
using Azure.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -103,6 +105,50 @@ public static IRegistrationBuilder<CloudBlobClientWrapper, SimpleActivatorData,
});
}

public static TableServiceClient CreateTableServiceClient(
this IServiceProvider serviceProvider,
string storageConnectionString)
{
if (serviceProvider == null)
{
throw new ArgumentNullException(nameof(serviceProvider));
}
if (string.IsNullOrWhiteSpace(storageConnectionString))
{
throw new ArgumentException($"{nameof(storageConnectionString)} cannot be null or empty.", nameof(storageConnectionString));
}

StorageMsiConfiguration msiConfiguration = serviceProvider.GetRequiredService<IOptions<StorageMsiConfiguration>>().Value;
return CreateTableServiceClientClient(
msiConfiguration,
storageConnectionString);
}

public static IRegistrationBuilder<TableServiceClient, SimpleActivatorData, SingleRegistrationStyle> RegisterTableServiceClient<TConfiguration>(
this ContainerBuilder builder,
Func<TConfiguration, string> getConnectionString)
where TConfiguration : class, new()
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
if (getConnectionString == null)
{
throw new ArgumentNullException(nameof(getConnectionString));
}

return builder.Register(c =>
{
IOptionsSnapshot<TConfiguration> options = c.Resolve<IOptionsSnapshot<TConfiguration>>();
string storageConnectionString = getConnectionString(options.Value);
StorageMsiConfiguration msiConfiguration = c.Resolve<IOptions<StorageMsiConfiguration>>().Value;
return CreateTableServiceClientClient(
msiConfiguration,
storageConnectionString);
});
}

private static CloudBlobClientWrapper CreateCloudBlobClient(
StorageMsiConfiguration msiConfiguration,
string storageConnectionString,
Expand Down Expand Up @@ -133,5 +179,28 @@ private static CloudBlobClientWrapper CreateCloudBlobClient(
readAccessGeoRedundant,
requestTimeout);
}

private static TableServiceClient CreateTableServiceClientClient(
StorageMsiConfiguration msiConfiguration,
string tableStorageConnectionString)
{
if (msiConfiguration.UseManagedIdentity)
{
if (string.IsNullOrWhiteSpace(msiConfiguration.ManagedIdentityClientId))
{
return new TableServiceClient(new Uri(tableStorageConnectionString),
new DefaultAzureCredential());
}
else
{
return new TableServiceClient(new Uri(tableStorageConnectionString),
new ManagedIdentityCredential(msiConfiguration.ManagedIdentityClientId));
}
}

// workaround for https://github.com/Azure/azure-sdk-for-net/issues/44373
tableStorageConnectionString.Replace("SharedAccessSignature=?", "SharedAccessSignature=");
return new TableServiceClient(tableStorageConnectionString);
}
}
}
43 changes: 43 additions & 0 deletions src/NuGetGallery.Core/Extensions/UriExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NuGetGallery.Extensions
{
public static class UriExtensions
{
/// <summary>
/// Appends the given SAS token to the Uri, ensuring the query string is correctly formatted.
/// </summary>
/// <param name="uri">The base Uri to which the query string will be appended.</param>
/// <param name="sas">The SAS string to append, which may or may not start with a '?' character.</param>
/// <returns>A new Uri with the SAS string appended.</returns>
public static Uri BlobStorageAppendSas(this Uri uri, string sas)
{
if (uri == null)
{
throw new ArgumentNullException(nameof(uri));
}

if (string.IsNullOrEmpty(sas))
{
throw new ArgumentNullException(nameof(sas));
}

// Trim any leading '?' from the query string to avoid double '?'
string trimmedQueryString = sas.TrimStart('?');

var uriBuilder = new UriBuilder(uri)
{
Query = trimmedQueryString
};

return uriBuilder.Uri;
}
}
}
28 changes: 23 additions & 5 deletions src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
Expand All @@ -10,6 +10,7 @@
using System.Net;
using System.Threading.Tasks;
using NuGetGallery.Diagnostics;
using NuGetGallery.Extensions;
using LogLevel = Microsoft.Extensions.Logging.LogLevel;

namespace NuGetGallery
Expand Down Expand Up @@ -371,10 +372,27 @@ public async Task<Uri> GetPrivilegedFileUriAsync(
throw new ArgumentOutOfRangeException(nameof(endOfAccess), $"{nameof(endOfAccess)} is in the past");
}

var blob = await GetBlobForUriAsync(folderName, fileName);
ISimpleCloudBlob blob = await GetBlobForUriAsync(folderName, fileName);
string sas = await blob.GetSharedAccessSignature(permissions, endOfAccess);

return new Uri(blob.Uri, sas);
return blob.Uri.BlobStorageAppendSas(sas);
}

public async Task<Uri> GetPrivilegedFileUriWithDelegationSasAsync(
string folderName,
string fileName,
FileUriPermissions permissions,
DateTimeOffset endOfAccess)
{
if (endOfAccess < DateTimeOffset.UtcNow)
{
throw new ArgumentOutOfRangeException(nameof(endOfAccess), $"{nameof(endOfAccess)} is in the past");
}

ISimpleCloudBlob blob = await GetBlobForUriAsync(folderName, fileName);
string sas = await blob.GetDelegationSasAsync(permissions, endOfAccess);

return blob.Uri.BlobStorageAppendSas(sas);
}

public async Task<Uri> GetFileReadUriAsync(string folderName, string fileName, DateTimeOffset? endOfAccess)
Expand All @@ -398,7 +416,7 @@ public async Task<Uri> GetFileReadUriAsync(string folderName, string fileName, D

string sas = await blob.GetSharedAccessSignature(FileUriPermissions.Read, endOfAccess.Value);

return new Uri(blob.Uri, sas);
return blob.Uri.BlobStorageAppendSas(sas);
}

/// <summary>
Expand Down Expand Up @@ -599,4 +617,4 @@ public StorageResult(HttpStatusCode statusCode, Stream data, string etag)
}
}
}
}
}
68 changes: 49 additions & 19 deletions src/NuGetGallery.Core/Services/CloudBlobWrapper.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
Expand Down Expand Up @@ -327,37 +327,37 @@ private void ReplaceMetadata(IDictionary<string, string> newMetadata)

public async Task<string> GetSharedAccessSignature(FileUriPermissions permissions, DateTimeOffset endOfAccess)
{
var sasBuilder = new BlobSasBuilder
{
BlobContainerName = _blob.BlobContainerName,
BlobName = _blob.Name,
Resource = "b",
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresOn = endOfAccess,
};
sasBuilder.SetPermissions(CloudWrapperHelpers.GetSdkSharedAccessPermissions(permissions));
BlobSasBuilder sasBuilder = CreateSasBuilderWithPermission(permissions, endOfAccess);

if (_blob.CanGenerateSasUri)
{
// regular SAS
return _blob.GenerateSasUri(sasBuilder).Query;
}
else if (_container?.Account?.UsingTokenCredential == true && _container?.Account?.Client != null)
else if (IsUsingDelegationSas())
{
// user delegation SAS
var userDelegationKey = (await _container.Account.Client.GetUserDelegationKeyAsync(sasBuilder.StartsOn, sasBuilder.ExpiresOn)).Value;
var blobUriBuilder = new BlobUriBuilder(_blob.Uri)
{
Sas = sasBuilder.ToSasQueryParameters(userDelegationKey, _blob.AccountName),
};
return blobUriBuilder.ToUri().Query;
return await GenerateDelegationSasAsync(sasBuilder);
}
else
{
throw new InvalidOperationException("Unsupported blob authentication");
}
}

public async Task<string> GetDelegationSasAsync(FileUriPermissions permissions, DateTimeOffset endOfAccess)
{
BlobSasBuilder sasBuilder = CreateSasBuilderWithPermission(permissions, endOfAccess);

if (IsUsingDelegationSas())
{
return await GenerateDelegationSasAsync(sasBuilder);
}
else
{
throw new InvalidOperationException("Unsupported blob authentication, managed identity required for this method.");
}
}

public async Task StartCopyAsync(ISimpleCloudBlob source, IAccessCondition sourceAccessCondition, IAccessCondition destAccessCondition)
{
// To avoid this we would need to somehow abstract away the primary and secondary storage locations. This
Expand Down Expand Up @@ -531,5 +531,35 @@ private void UpdateEtag(BlobDownloadDetails details)
// workaround for https://github.com/Azure/azure-sdk-for-net/issues/29942
private static string EtagToString(ETag etag)
=> etag.ToString("H");

private BlobSasBuilder CreateSasBuilderWithPermission(FileUriPermissions permissions, DateTimeOffset endOfAccess)
{
var sasBuilder = new BlobSasBuilder
{
BlobContainerName = _blob.BlobContainerName,
BlobName = _blob.Name,
Resource = "b",
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
ExpiresOn = endOfAccess,
};
sasBuilder.SetPermissions(CloudWrapperHelpers.GetSdkSharedAccessPermissions(permissions));

return sasBuilder;
}

private bool IsUsingDelegationSas()
{
return _container?.Account?.UsingTokenCredential == true && _container?.Account?.Client != null;
}

private async Task<string> GenerateDelegationSasAsync(BlobSasBuilder sasBuilder)
{
UserDelegationKey userDelegationKey = (await _container.Account.Client.GetUserDelegationKeyAsync(sasBuilder.StartsOn, sasBuilder.ExpiresOn)).Value;
BlobUriBuilder blobUriBuilder = new BlobUriBuilder(_blob.Uri)
{
Sas = sasBuilder.ToSasQueryParameters(userDelegationKey, _blob.AccountName),
};
return blobUriBuilder.ToUri().Query;
}
}
}
}
20 changes: 18 additions & 2 deletions src/NuGetGallery.Core/Services/ICoreFileStorageService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
Expand Down Expand Up @@ -59,6 +59,22 @@ Task<Uri> GetPrivilegedFileUriAsync(
FileUriPermissions permissions,
DateTimeOffset endOfAccess);

/// <summary>
/// Generates a storage file URI giving certain permissions for the specific file via delegation SAS. For example, this method can
/// be used to generate a URI that allows the caller to either delete (via
/// <see cref="FileUriPermissions.Delete"/>) or read (via <see cref="FileUriPermissions.Read"/>) the file.
/// </summary>
/// <param name="folderName">The folder name containing the file.</param>
/// <param name="fileName">The file name.</param>
/// <param name="permissions">The permissions to give to the privileged URI.</param>
/// <param name="endOfAccess">The time when the access ends.</param>
/// <returns>The URI with privileged delegation SAS access.</returns>
Task<Uri> GetPrivilegedFileUriWithDelegationSasAsync(
string folderName,
string fileName,
FileUriPermissions permissions,
DateTimeOffset endOfAccess);

Task SaveFileAsync(string folderName, string fileName, Stream file, bool overwrite = true);

/// <summary>
Expand Down Expand Up @@ -166,4 +182,4 @@ Task<string> GetETagOrNullAsync(
string folderName,
string fileName);
}
}
}
18 changes: 16 additions & 2 deletions src/NuGetGallery.Core/Services/ISimpleCloudBlob.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
Expand Down Expand Up @@ -52,6 +52,20 @@ public interface ISimpleCloudBlob
/// <returns>Shared access signature in form of URI query portion.</returns>
Task<string> GetSharedAccessSignature(FileUriPermissions permissions, DateTimeOffset endOfAccess);

/// <summary>
/// Generates a new delegation sas token that if appended to the blob URI
/// would allow actions matching the provided <paramref name="permissions"/> without having access to the
/// access keys of the storage account.
/// </summary>
/// <param name="permissions">The permissions to include in the SAS token.</param>
/// <param name="endOfAccess">
/// "End of access" timestamp. After the specified timestamp,
/// the returned signature becomes invalid if implementation supports it.
/// Null for no time limit.
/// </param>
/// <returns>Delegation SAS in form of URI query portion.</returns>
Task<string> GetDelegationSasAsync(FileUriPermissions permissions, DateTimeOffset endOfAccess);

/// <summary>
/// Opens the seekable read stream to the file in blob storage.
/// </summary>
Expand Down Expand Up @@ -84,4 +98,4 @@ Task<Stream> OpenReadStreamAsync(
/// <returns>Stream if the call was successful, null if blob does not exist.</returns>
Task<Stream> OpenReadIfExistsAsync();
}
}
}
5 changes: 5 additions & 0 deletions src/NuGetGallery/Services/FileSystemFileStorageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,11 @@ public Task<Uri> GetPrivilegedFileUriAsync(string folderName, string fileName, F
throw new NotImplementedException();
}

public Task<Uri> GetPrivilegedFileUriWithDelegationSasAsync(string folderName, string fileName, FileUriPermissions permissions, DateTimeOffset endOfAccess)
{
throw new NotImplementedException();
}

public Task SetMetadataAsync(
string folderName,
string fileName,
Expand Down
7 changes: 6 additions & 1 deletion tests/NuGet.Services.V3.Tests/Support/InMemoryCloudBlob.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
Expand Down Expand Up @@ -106,6 +106,11 @@ public Task<bool> FetchAttributesIfExistsAsync()
throw new NotImplementedException();
}

public Task<string> GetDelegationSasAsync(FileUriPermissions permissions, DateTimeOffset endOfAccess)
{
throw new NotImplementedException();
}

public Task<string> GetSharedAccessSignature(FileUriPermissions permissions, DateTimeOffset endOfAccess)
{
throw new NotImplementedException();
Expand Down
Loading

0 comments on commit 4c6725a

Please sign in to comment.