Skip to content

Commit

Permalink
Feature/DPS RBAC support (#2297)
Browse files Browse the repository at this point in the history
* Revert "Revert "Adding RBAC support for provisioning SDK (#2262)""

This reverts commit 1a0b9de.

* Revert "Revert "Adding RBAC support for provisioning SDK (#2262)""

This reverts commit 1a0b9de.

* Basic test layout

* API update

* Style updates

* Fix namespace issue

* Misc cleanup

* Added new field

* Reverted e2e script changes

* Changing <code> to <c>

* Replaced the rest

* Updating ArgumentException arguments

* Removed digital twin comments

* Revert "Added new field"

This reverts commit 60a3475.

* Added URL encoding

* Added a few comments

* Fixed syntax error

Co-authored-by: Azad Abbasi <[email protected]>
Co-authored-by: David R. Williamson <[email protected]>
  • Loading branch information
3 people authored Mar 3, 2022
1 parent b1caf77 commit ed49d32
Show file tree
Hide file tree
Showing 13 changed files with 619 additions and 174 deletions.
72 changes: 71 additions & 1 deletion e2e/test/config/TestConfiguration.Provisioning.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Microsoft.Azure.Devices.E2ETests.Provisioning;

#if !NET451

using Azure.Identity;

#endif

namespace Microsoft.Azure.Devices.E2ETests
{
Expand All @@ -14,6 +25,34 @@ public static partial class Provisioning

public static string ConnectionString => GetValue("PROVISIONING_CONNECTION_STRING");

public static string GetProvisioningHostName()
{
var connectionString = new ConnectionStringParser(ConnectionString);
return connectionString.ProvisioningHostName;
}

#if !NET451

public static ClientSecretCredential GetClientSecretCredential()
{
return new ClientSecretCredential(
GetValue("MSFT_TENANT_ID"),
GetValue("IOTHUB_CLIENT_ID"),
GetValue("IOTHUB_CLIENT_SECRET"));
}

#endif

public static string GetProvisioningSharedAccessSignature(TimeSpan timeToLive)
{
var connectionString = new ConnectionStringParser(ConnectionString);
return GenerateSasToken(
connectionString.ProvisioningHostName,
connectionString.SharedAccessKey,
timeToLive,
connectionString.SharedAccessKeyName);
}

public static string GlobalDeviceEndpoint =>
GetValue("DPS_GLOBALDEVICEENDPOINT", "global.azure-devices-provisioning.net");

Expand All @@ -38,6 +77,37 @@ public static X509Certificate2Collection GetGroupEnrollmentChain()
public static string FarAwayIotHubHostName => GetValue("FAR_AWAY_IOTHUB_HOSTNAME");

public static string CustomAllocationPolicyWebhook => GetValue("CUSTOM_ALLOCATION_POLICY_WEBHOOK");

private static string GenerateSasToken(string resourceUri, string sharedAccessKey, TimeSpan timeToLive, string policyName = default)
{
// Calculate expiry value for token
var epochTime = new DateTime(1970, 1, 1);
DateTime expiresOn = DateTime.UtcNow.Add(timeToLive);
TimeSpan secondsFromEpochTime = expiresOn.Subtract(epochTime);
long seconds = Convert.ToInt64(secondsFromEpochTime.TotalSeconds, CultureInfo.InvariantCulture);
string expiry = Convert.ToString(seconds, CultureInfo.InvariantCulture);

string stringToSign = WebUtility.UrlEncode(resourceUri) + "\n" + expiry;

using var hmac = new HMACSHA256(Convert.FromBase64String(sharedAccessKey));
string signature = Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign)));

// SharedAccessSignature sr=ENCODED(dh://mydps.azure-devices-provisioning.net/a/b/c?myvalue1=a)&sig=<Signature>&se=<ExpiresOnValue>[&skn=<KeyName>]
string token = string.Format(
CultureInfo.InvariantCulture,
"SharedAccessSignature sr={0}&sig={1}&se={2}",
WebUtility.UrlEncode(resourceUri),
WebUtility.UrlEncode(signature),
expiry);

// add policy name only if user chooses to include it
if (!string.IsNullOrWhiteSpace(policyName))
{
token += "&skn=" + WebUtility.UrlEncode(policyName);
}

return token;
}
}
}
}
61 changes: 61 additions & 0 deletions e2e/test/provisioning/ConnectionStringParser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System;

namespace Microsoft.Azure.Devices.E2ETests.Provisioning
{
internal class ConnectionStringParser
{
public string ProvisioningHostName { get; private set; }

public string DeviceId { get; private set; }

public string SharedAccessKey { get; private set; }

public string SharedAccessKeyName { get; private set; }

public ConnectionStringParser(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException(nameof(connectionString), "Parameter cannot be null, empty, or whitespace.");
}

// Connection string sections are demarcated with semicolon
string[] parts = connectionString.Split(';');

foreach (string part in parts)
{
int separatorIndex = part.IndexOf('=');
if (separatorIndex < 0)
{
throw new ArgumentException($"Improperly formatted key/value pair: {part}.");
}

string key = part.Substring(0, separatorIndex);
string value = part.Substring(separatorIndex + 1);

switch (key.ToUpperInvariant())
{
case "HOSTNAME":
// Gives the correct Host Name to send requests to
ProvisioningHostName = value;
break;

case "SHAREDACCESSKEY":
SharedAccessKey = value;
break;

case "DEVICEID":
DeviceId = value;
break;

case "SHAREDACCESSKEYNAME":
SharedAccessKeyName = value;
break;

default:
throw new NotSupportedException($"Unrecognized tag found in parameter {nameof(connectionString)}.");
}
}
}
}
}
59 changes: 59 additions & 0 deletions e2e/test/provisioning/ProvisioningServiceClientE2ETests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Azure;
using FluentAssertions;
using Microsoft.Azure.Devices.Provisioning.Security.Samples;
using Microsoft.Azure.Devices.Provisioning.Service;
using Microsoft.Azure.Devices.Shared;
Expand Down Expand Up @@ -110,6 +112,63 @@ public async Task ProvisioningServiceClient_GetEnrollmentGroupAttestation_Symmet
await ProvisioningServiceClient_GetEnrollmentGroupAttestation(AttestationMechanismType.SymmetricKey);
}

[LoggedTestMethod]
public async Task ProvisioningServiceClient_TokenCredentialAuth_Success()
{
// arrange
using var provisioningServiceClient = ProvisioningServiceClient.Create(
TestConfiguration.Provisioning.GetProvisioningHostName(),
TestConfiguration.Provisioning.GetClientSecretCredential());

IndividualEnrollment individualEnrollment = await CreateIndividualEnrollment(
provisioningServiceClient,
AttestationMechanismType.SymmetricKey,
null,
AllocationPolicy.Static,
null,
null,
null)
.ConfigureAwait(false);

// act
IndividualEnrollment individualEnrollmentResult = await provisioningServiceClient.CreateOrUpdateIndividualEnrollmentAsync(individualEnrollment);

// assert
individualEnrollmentResult.Should().NotBeNull();

// cleanup
await provisioningServiceClient.DeleteIndividualEnrollmentAsync(individualEnrollment.RegistrationId);
}

[LoggedTestMethod]
public async Task ProvisioningServiceClient_AzureSasCredentialAuth_Success()
{
// arrange
string signature = TestConfiguration.Provisioning.GetProvisioningSharedAccessSignature(TimeSpan.FromHours(1));
using var provisioningServiceClient = ProvisioningServiceClient.Create(
TestConfiguration.Provisioning.GetProvisioningHostName(),
new AzureSasCredential(signature));

IndividualEnrollment individualEnrollment = await CreateIndividualEnrollment(
provisioningServiceClient,
AttestationMechanismType.SymmetricKey,
null,
AllocationPolicy.Static,
null,
null,
null)
.ConfigureAwait(false);

// act
IndividualEnrollment individualEnrollmentResult = await provisioningServiceClient.CreateOrUpdateIndividualEnrollmentAsync(individualEnrollment);

// assert
individualEnrollmentResult.Should().NotBeNull();

// cleanup
await provisioningServiceClient.DeleteIndividualEnrollmentAsync(individualEnrollment.RegistrationId);
}

public async Task ProvisioningServiceClient_GetIndividualEnrollmentAttestation(AttestationMechanismType attestationType)
{
using var provisioningServiceClient = ProvisioningServiceClient.CreateFromConnectionString(TestConfiguration.Provisioning.ConnectionString);
Expand Down
25 changes: 25 additions & 0 deletions provisioning/service/src/Auth/ProvisioningSasCredential.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Azure;
using Microsoft.Azure.Devices.Common.Service.Auth;

namespace Microsoft.Azure.Devices.Provisioning.Service.Auth
{
/// <summary>
/// Allows authentication to the API using a Shared Access Key provided by custom implementation.
/// </summary>
internal class ProvisioningSasCredential : IAuthorizationHeaderProvider
{
private readonly AzureSasCredential _azureSasCredential;

public ProvisioningSasCredential(AzureSasCredential azureSasCredential)
{
_azureSasCredential = azureSasCredential;
}

public string GetAuthorizationHeader()
{
return _azureSasCredential.Signature;
}
}
}
41 changes: 41 additions & 0 deletions provisioning/service/src/Auth/ProvisioningTokenCredential.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System.Threading;
using Azure.Core;
using Microsoft.Azure.Devices.Common.Service.Auth;

namespace Microsoft.Azure.Devices.Provisioning.Service.Auth
{
/// <summary>
/// Allows authentication to the API using a JWT token generated for Azure active directory.
/// </summary>
internal class ProvisioningTokenCredential : IAuthorizationHeaderProvider
{
private readonly TokenCredential _credential;
private readonly object _tokenLock = new object();
private AccessToken? _cachedAccessToken;

public ProvisioningTokenCredential(TokenCredential credential)
{
_credential = credential;
}

// The HTTP protocol uses this method to get the bearer token for authentication.
public string GetAuthorizationHeader()
{
lock (_tokenLock)
{
// A new token is generated if it is the first time or the cached token is close to expiry.
if (!_cachedAccessToken.HasValue
|| TokenHelper.IsCloseToExpiry(_cachedAccessToken.Value.ExpiresOn))
{
_cachedAccessToken = _credential.GetToken(
new TokenRequestContext(new string[] { "https://azure-devices-provisioning.net/.default" }),
new CancellationToken());
}
}

return $"Bearer {_cachedAccessToken.Value.Token}";
}
}
}
21 changes: 21 additions & 0 deletions provisioning/service/src/Auth/TokenHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using System;

namespace Microsoft.Azure.Devices.Provisioning.Service.Auth
{
internal static class TokenHelper
{
/// <summary>
/// Determines if the given token expiry date time is close to expiry. The date and time is
/// considered close to expiry if it has less than 10 minutes relative to the current time.
/// </summary>
/// <param name="expiry">The token expiration date and time.</param>
/// <returns>True if the token expiry has less than 10 minutes relative to the current time, otherwise false.</returns>
public static bool IsCloseToExpiry(DateTimeOffset expiry)
{
TimeSpan timeToExpiry = expiry - DateTimeOffset.UtcNow;
return timeToExpiry.TotalMinutes < 10;
}
}
}
2 changes: 1 addition & 1 deletion provisioning/service/src/Contract/SDKUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Microsoft.Azure.Devices.Provisioning.Service
{
internal class SDKUtils
{
private const string ApiVersionProvisioning = "2019-03-31";
private const string ApiVersionProvisioning = "2021-10-01";
public const string ApiVersionQueryString = CustomHeaderConstants.ApiVersion + "=" + ApiVersionProvisioning;
}
}
5 changes: 3 additions & 2 deletions provisioning/service/src/Manager/EnrollmentGroupManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ await contractApiHttp.RequestAsync(
}

internal static Query CreateQuery(
ServiceConnectionString provisioningConnectionString,
string hostName,
IAuthorizationHeaderProvider headerProvider,
QuerySpecification querySpecification,
HttpTransportSettings httpTransportSettings,
CancellationToken cancellationToken,
Expand All @@ -128,7 +129,7 @@ internal static Query CreateQuery(

/* SRS_ENROLLMENT_GROUP_MANAGER_28_015: [The CreateQuery shall return a new Query for EnrollmentGroup.] */

return new Query(provisioningConnectionString, ServiceName, querySpecification, httpTransportSettings, pageSize, cancellationToken);
return new Query(hostName, headerProvider, ServiceName, querySpecification, httpTransportSettings, pageSize, cancellationToken);
}

private static Uri GetEnrollmentUri(string enrollmentGroupId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ await contractApiHttp.RequestAsync(
}

internal static Query CreateQuery(
ServiceConnectionString provisioningConnectionString,
string hostName,
IAuthorizationHeaderProvider headerProvider,
QuerySpecification querySpecification,
HttpTransportSettings httpTransportSettings,
CancellationToken cancellationToken,
Expand All @@ -168,7 +169,7 @@ internal static Query CreateQuery(
}

/* SRS_INDIVIDUAL_ENROLLMENT_MANAGER_21_015: [The CreateQuery shall return a new Query for IndividualEnrollments.] */
return new Query(provisioningConnectionString, ServiceName, querySpecification, httpTransportSettings, pageSize, cancellationToken);
return new Query(hostName, headerProvider, ServiceName, querySpecification, httpTransportSettings, pageSize, cancellationToken);
}

private static Uri GetEnrollmentUri(string registrationId)
Expand Down
6 changes: 4 additions & 2 deletions provisioning/service/src/Manager/RegistrationStatusManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ await contractApiHttp.RequestAsync(
[SuppressMessage("Microsoft.Design", "CA1068",
Justification = "Public API cannot change parameter order.")]
internal static Query CreateEnrollmentGroupQuery(
ServiceConnectionString provisioningConnectionString,
string hostName,
IAuthorizationHeaderProvider headerProvider,
QuerySpecification querySpecification,
HttpTransportSettings httpTransportSettings,
CancellationToken cancellationToken,
Expand All @@ -102,7 +103,8 @@ internal static Query CreateEnrollmentGroupQuery(

/* SRS_REGISTRATION_STATUS_MANAGER_28_010: [The CreateQuery shall return a new Query for DeviceRegistrationState.] */
return new Query(
provisioningConnectionString,
hostName,
headerProvider,
GetGetDeviceRegistrationStatus(enrollmentGroupId),
querySpecification,
httpTransportSettings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Azure.Core" Version="1.9.0" />
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="6.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Loading

0 comments on commit ed49d32

Please sign in to comment.