Skip to content

Commit 4c6725a

Browse files
authored
Introduce delegation SAS (#10159)
1 parent 5c62798 commit 4c6725a

10 files changed

Lines changed: 335 additions & 54 deletions

File tree

src/NuGet.Jobs.Common/NuGet.Jobs.Common.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
<ItemGroup>
1515
<PackageReference Include="Autofac.Extensions.DependencyInjection" />
16+
<PackageReference Include="Azure.Data.Tables" />
1617
<PackageReference Include="Dapper.StrongName" />
1718
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
1819
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />

src/NuGet.Jobs.Common/StorageAccountExtensions.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using System;
55
using Autofac;
66
using Autofac.Builder;
7+
using Azure.Data.Tables;
8+
using Azure.Identity;
79
using Microsoft.Extensions.Configuration;
810
using Microsoft.Extensions.DependencyInjection;
911
using Microsoft.Extensions.Options;
@@ -103,6 +105,50 @@ public static IRegistrationBuilder<CloudBlobClientWrapper, SimpleActivatorData,
103105
});
104106
}
105107

108+
public static TableServiceClient CreateTableServiceClient(
109+
this IServiceProvider serviceProvider,
110+
string storageConnectionString)
111+
{
112+
if (serviceProvider == null)
113+
{
114+
throw new ArgumentNullException(nameof(serviceProvider));
115+
}
116+
if (string.IsNullOrWhiteSpace(storageConnectionString))
117+
{
118+
throw new ArgumentException($"{nameof(storageConnectionString)} cannot be null or empty.", nameof(storageConnectionString));
119+
}
120+
121+
StorageMsiConfiguration msiConfiguration = serviceProvider.GetRequiredService<IOptions<StorageMsiConfiguration>>().Value;
122+
return CreateTableServiceClientClient(
123+
msiConfiguration,
124+
storageConnectionString);
125+
}
126+
127+
public static IRegistrationBuilder<TableServiceClient, SimpleActivatorData, SingleRegistrationStyle> RegisterTableServiceClient<TConfiguration>(
128+
this ContainerBuilder builder,
129+
Func<TConfiguration, string> getConnectionString)
130+
where TConfiguration : class, new()
131+
{
132+
if (builder == null)
133+
{
134+
throw new ArgumentNullException(nameof(builder));
135+
}
136+
if (getConnectionString == null)
137+
{
138+
throw new ArgumentNullException(nameof(getConnectionString));
139+
}
140+
141+
return builder.Register(c =>
142+
{
143+
IOptionsSnapshot<TConfiguration> options = c.Resolve<IOptionsSnapshot<TConfiguration>>();
144+
string storageConnectionString = getConnectionString(options.Value);
145+
StorageMsiConfiguration msiConfiguration = c.Resolve<IOptions<StorageMsiConfiguration>>().Value;
146+
return CreateTableServiceClientClient(
147+
msiConfiguration,
148+
storageConnectionString);
149+
});
150+
}
151+
106152
private static CloudBlobClientWrapper CreateCloudBlobClient(
107153
StorageMsiConfiguration msiConfiguration,
108154
string storageConnectionString,
@@ -133,5 +179,28 @@ private static CloudBlobClientWrapper CreateCloudBlobClient(
133179
readAccessGeoRedundant,
134180
requestTimeout);
135181
}
182+
183+
private static TableServiceClient CreateTableServiceClientClient(
184+
StorageMsiConfiguration msiConfiguration,
185+
string tableStorageConnectionString)
186+
{
187+
if (msiConfiguration.UseManagedIdentity)
188+
{
189+
if (string.IsNullOrWhiteSpace(msiConfiguration.ManagedIdentityClientId))
190+
{
191+
return new TableServiceClient(new Uri(tableStorageConnectionString),
192+
new DefaultAzureCredential());
193+
}
194+
else
195+
{
196+
return new TableServiceClient(new Uri(tableStorageConnectionString),
197+
new ManagedIdentityCredential(msiConfiguration.ManagedIdentityClientId));
198+
}
199+
}
200+
201+
// workaround for https://github.com/Azure/azure-sdk-for-net/issues/44373
202+
tableStorageConnectionString.Replace("SharedAccessSignature=?", "SharedAccessSignature=");
203+
return new TableServiceClient(tableStorageConnectionString);
204+
}
136205
}
137206
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Text;
8+
using System.Threading.Tasks;
9+
10+
namespace NuGetGallery.Extensions
11+
{
12+
public static class UriExtensions
13+
{
14+
/// <summary>
15+
/// Appends the given SAS token to the Uri, ensuring the query string is correctly formatted.
16+
/// </summary>
17+
/// <param name="uri">The base Uri to which the query string will be appended.</param>
18+
/// <param name="sas">The SAS string to append, which may or may not start with a '?' character.</param>
19+
/// <returns>A new Uri with the SAS string appended.</returns>
20+
public static Uri BlobStorageAppendSas(this Uri uri, string sas)
21+
{
22+
if (uri == null)
23+
{
24+
throw new ArgumentNullException(nameof(uri));
25+
}
26+
27+
if (string.IsNullOrEmpty(sas))
28+
{
29+
throw new ArgumentNullException(nameof(sas));
30+
}
31+
32+
// Trim any leading '?' from the query string to avoid double '?'
33+
string trimmedQueryString = sas.TrimStart('?');
34+
35+
var uriBuilder = new UriBuilder(uri)
36+
{
37+
Query = trimmedQueryString
38+
};
39+
40+
return uriBuilder.Uri;
41+
}
42+
}
43+
}

src/NuGetGallery.Core/Services/CloudBlobCoreFileStorageService.cs

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -10,6 +10,7 @@
1010
using System.Net;
1111
using System.Threading.Tasks;
1212
using NuGetGallery.Diagnostics;
13+
using NuGetGallery.Extensions;
1314
using LogLevel = Microsoft.Extensions.Logging.LogLevel;
1415

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

374-
var blob = await GetBlobForUriAsync(folderName, fileName);
375+
ISimpleCloudBlob blob = await GetBlobForUriAsync(folderName, fileName);
375376
string sas = await blob.GetSharedAccessSignature(permissions, endOfAccess);
376377

377-
return new Uri(blob.Uri, sas);
378+
return blob.Uri.BlobStorageAppendSas(sas);
379+
}
380+
381+
public async Task<Uri> GetPrivilegedFileUriWithDelegationSasAsync(
382+
string folderName,
383+
string fileName,
384+
FileUriPermissions permissions,
385+
DateTimeOffset endOfAccess)
386+
{
387+
if (endOfAccess < DateTimeOffset.UtcNow)
388+
{
389+
throw new ArgumentOutOfRangeException(nameof(endOfAccess), $"{nameof(endOfAccess)} is in the past");
390+
}
391+
392+
ISimpleCloudBlob blob = await GetBlobForUriAsync(folderName, fileName);
393+
string sas = await blob.GetDelegationSasAsync(permissions, endOfAccess);
394+
395+
return blob.Uri.BlobStorageAppendSas(sas);
378396
}
379397

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

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

401-
return new Uri(blob.Uri, sas);
419+
return blob.Uri.BlobStorageAppendSas(sas);
402420
}
403421

404422
/// <summary>
@@ -599,4 +617,4 @@ public StorageResult(HttpStatusCode statusCode, Stream data, string etag)
599617
}
600618
}
601619
}
602-
}
620+
}

src/NuGetGallery.Core/Services/CloudBlobWrapper.cs

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -327,37 +327,37 @@ private void ReplaceMetadata(IDictionary<string, string> newMetadata)
327327

328328
public async Task<string> GetSharedAccessSignature(FileUriPermissions permissions, DateTimeOffset endOfAccess)
329329
{
330-
var sasBuilder = new BlobSasBuilder
331-
{
332-
BlobContainerName = _blob.BlobContainerName,
333-
BlobName = _blob.Name,
334-
Resource = "b",
335-
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
336-
ExpiresOn = endOfAccess,
337-
};
338-
sasBuilder.SetPermissions(CloudWrapperHelpers.GetSdkSharedAccessPermissions(permissions));
330+
BlobSasBuilder sasBuilder = CreateSasBuilderWithPermission(permissions, endOfAccess);
339331

340332
if (_blob.CanGenerateSasUri)
341333
{
342334
// regular SAS
343335
return _blob.GenerateSasUri(sasBuilder).Query;
344336
}
345-
else if (_container?.Account?.UsingTokenCredential == true && _container?.Account?.Client != null)
337+
else if (IsUsingDelegationSas())
346338
{
347-
// user delegation SAS
348-
var userDelegationKey = (await _container.Account.Client.GetUserDelegationKeyAsync(sasBuilder.StartsOn, sasBuilder.ExpiresOn)).Value;
349-
var blobUriBuilder = new BlobUriBuilder(_blob.Uri)
350-
{
351-
Sas = sasBuilder.ToSasQueryParameters(userDelegationKey, _blob.AccountName),
352-
};
353-
return blobUriBuilder.ToUri().Query;
339+
return await GenerateDelegationSasAsync(sasBuilder);
354340
}
355341
else
356342
{
357343
throw new InvalidOperationException("Unsupported blob authentication");
358344
}
359345
}
360346

347+
public async Task<string> GetDelegationSasAsync(FileUriPermissions permissions, DateTimeOffset endOfAccess)
348+
{
349+
BlobSasBuilder sasBuilder = CreateSasBuilderWithPermission(permissions, endOfAccess);
350+
351+
if (IsUsingDelegationSas())
352+
{
353+
return await GenerateDelegationSasAsync(sasBuilder);
354+
}
355+
else
356+
{
357+
throw new InvalidOperationException("Unsupported blob authentication, managed identity required for this method.");
358+
}
359+
}
360+
361361
public async Task StartCopyAsync(ISimpleCloudBlob source, IAccessCondition sourceAccessCondition, IAccessCondition destAccessCondition)
362362
{
363363
// To avoid this we would need to somehow abstract away the primary and secondary storage locations. This
@@ -531,5 +531,35 @@ private void UpdateEtag(BlobDownloadDetails details)
531531
// workaround for https://github.com/Azure/azure-sdk-for-net/issues/29942
532532
private static string EtagToString(ETag etag)
533533
=> etag.ToString("H");
534+
535+
private BlobSasBuilder CreateSasBuilderWithPermission(FileUriPermissions permissions, DateTimeOffset endOfAccess)
536+
{
537+
var sasBuilder = new BlobSasBuilder
538+
{
539+
BlobContainerName = _blob.BlobContainerName,
540+
BlobName = _blob.Name,
541+
Resource = "b",
542+
StartsOn = DateTimeOffset.UtcNow.AddMinutes(-5),
543+
ExpiresOn = endOfAccess,
544+
};
545+
sasBuilder.SetPermissions(CloudWrapperHelpers.GetSdkSharedAccessPermissions(permissions));
546+
547+
return sasBuilder;
548+
}
549+
550+
private bool IsUsingDelegationSas()
551+
{
552+
return _container?.Account?.UsingTokenCredential == true && _container?.Account?.Client != null;
553+
}
554+
555+
private async Task<string> GenerateDelegationSasAsync(BlobSasBuilder sasBuilder)
556+
{
557+
UserDelegationKey userDelegationKey = (await _container.Account.Client.GetUserDelegationKeyAsync(sasBuilder.StartsOn, sasBuilder.ExpiresOn)).Value;
558+
BlobUriBuilder blobUriBuilder = new BlobUriBuilder(_blob.Uri)
559+
{
560+
Sas = sasBuilder.ToSasQueryParameters(userDelegationKey, _blob.AccountName),
561+
};
562+
return blobUriBuilder.ToUri().Query;
563+
}
534564
}
535-
}
565+
}

src/NuGetGallery.Core/Services/ICoreFileStorageService.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -59,6 +59,22 @@ Task<Uri> GetPrivilegedFileUriAsync(
5959
FileUriPermissions permissions,
6060
DateTimeOffset endOfAccess);
6161

62+
/// <summary>
63+
/// Generates a storage file URI giving certain permissions for the specific file via delegation SAS. For example, this method can
64+
/// be used to generate a URI that allows the caller to either delete (via
65+
/// <see cref="FileUriPermissions.Delete"/>) or read (via <see cref="FileUriPermissions.Read"/>) the file.
66+
/// </summary>
67+
/// <param name="folderName">The folder name containing the file.</param>
68+
/// <param name="fileName">The file name.</param>
69+
/// <param name="permissions">The permissions to give to the privileged URI.</param>
70+
/// <param name="endOfAccess">The time when the access ends.</param>
71+
/// <returns>The URI with privileged delegation SAS access.</returns>
72+
Task<Uri> GetPrivilegedFileUriWithDelegationSasAsync(
73+
string folderName,
74+
string fileName,
75+
FileUriPermissions permissions,
76+
DateTimeOffset endOfAccess);
77+
6278
Task SaveFileAsync(string folderName, string fileName, Stream file, bool overwrite = true);
6379

6480
/// <summary>
@@ -166,4 +182,4 @@ Task<string> GetETagOrNullAsync(
166182
string folderName,
167183
string fileName);
168184
}
169-
}
185+
}

src/NuGetGallery.Core/Services/ISimpleCloudBlob.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

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

55+
/// <summary>
56+
/// Generates a new delegation sas token that if appended to the blob URI
57+
/// would allow actions matching the provided <paramref name="permissions"/> without having access to the
58+
/// access keys of the storage account.
59+
/// </summary>
60+
/// <param name="permissions">The permissions to include in the SAS token.</param>
61+
/// <param name="endOfAccess">
62+
/// "End of access" timestamp. After the specified timestamp,
63+
/// the returned signature becomes invalid if implementation supports it.
64+
/// Null for no time limit.
65+
/// </param>
66+
/// <returns>Delegation SAS in form of URI query portion.</returns>
67+
Task<string> GetDelegationSasAsync(FileUriPermissions permissions, DateTimeOffset endOfAccess);
68+
5569
/// <summary>
5670
/// Opens the seekable read stream to the file in blob storage.
5771
/// </summary>
@@ -84,4 +98,4 @@ Task<Stream> OpenReadStreamAsync(
8498
/// <returns>Stream if the call was successful, null if blob does not exist.</returns>
8599
Task<Stream> OpenReadIfExistsAsync();
86100
}
87-
}
101+
}

src/NuGetGallery/Services/FileSystemFileStorageService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,11 @@ public Task<Uri> GetPrivilegedFileUriAsync(string folderName, string fileName, F
261261
throw new NotImplementedException();
262262
}
263263

264+
public Task<Uri> GetPrivilegedFileUriWithDelegationSasAsync(string folderName, string fileName, FileUriPermissions permissions, DateTimeOffset endOfAccess)
265+
{
266+
throw new NotImplementedException();
267+
}
268+
264269
public Task SetMetadataAsync(
265270
string folderName,
266271
string fileName,

tests/NuGet.Services.V3.Tests/Support/InMemoryCloudBlob.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -106,6 +106,11 @@ public Task<bool> FetchAttributesIfExistsAsync()
106106
throw new NotImplementedException();
107107
}
108108

109+
public Task<string> GetDelegationSasAsync(FileUriPermissions permissions, DateTimeOffset endOfAccess)
110+
{
111+
throw new NotImplementedException();
112+
}
113+
109114
public Task<string> GetSharedAccessSignature(FileUriPermissions permissions, DateTimeOffset endOfAccess)
110115
{
111116
throw new NotImplementedException();

0 commit comments

Comments
 (0)