Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -918,12 +918,12 @@ function Test-CreateRAWhenIdNotExist
$RoleDefinitionId = "acdd72a7-3385-48ef-bd42-f606fba81ae7"
$PrincipalId ="6d764d35-6b3b-49ea-83f8-5c223b56eac5"
$Scope = '/subscriptions/4004a9fd-d58e-48dc-aeb2-4a4aec58606f'
$ExpectedError = 'Exception calling "ExecuteCmdlet" with "0" argument(s): "Operation returned an invalid status code ''BadRequest''"'
$ExpectedError = "PrincipalNotFound: Principal $($PrincipalId.Replace('-','')) does not exist in the directory"

#When
$function = { New-AzRoleAssignmentWithId -ObjectId $PrincipalId -Scope $Scope -RoleDefinitionId $RoleDefinitionId -RoleAssignmentId 0f7b6fb6-a5f4-4046-83eb-dfd93c5e4b72 }

Assert-Throws $function $ExpectedError
Assert-ThrowsContains $function $ExpectedError
}

<#
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
// ----------------------------------------------------------------------------------
//
// Copyright Microsoft Corporation
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ----------------------------------------------------------------------------------

using FluentAssertions;
using Microsoft.Azure.Commands.Common.Exceptions;
using Microsoft.Azure.Commands.Resources.Helper;
using Microsoft.Azure.Management.Authorization.Models;
using Microsoft.Rest;
using Microsoft.WindowsAzure.Commands.ScenarioTest;
using System.Net.Http;
using Xunit;

namespace Microsoft.Azure.Commands.Resources.Test.UnitTests
{
public class AuthorizationErrorResponseExceptionHelperTests
{
[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithStructuredBody_IncludesCodeAndMessage()
{
var ex = new ErrorResponseException("Operation returned an invalid status code 'Conflict'")
{
Body = new ErrorResponse(new ErrorDetail(
code: "RoleAssignmentExists",
message: "The role assignment already exists."))
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Should().BeOfType<AzPSCloudException>();
result.Message.Should().Contain("Operation returned an invalid status code 'Conflict'");
result.Message.Should().Contain("RoleAssignmentExists");
result.Message.Should().Contain("The role assignment already exists.");
result.InnerException.Should().Be(ex);
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithRawResponseContentOnly_IncludesResponseContent()
{
var ex = new ErrorResponseException("Operation returned an invalid status code 'NotFound'")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.NotFound),
"{\"error\":{\"code\":\"SubscriptionNotFound\",\"message\":\"The subscription could not be found.\"}}")
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Should().BeOfType<AzPSCloudException>();
result.Message.Should().Contain("Operation returned an invalid status code 'NotFound'");
result.Message.Should().Contain("SubscriptionNotFound");
result.InnerException.Should().Be(ex);
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithNoBodyOrResponse_FallsBackToOriginalMessage()
{
var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'");

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Should().BeOfType<AzPSCloudException>();
result.Message.Should().Be("Operation returned an invalid status code 'BadRequest'");
result.InnerException.Should().Be(ex);
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_PreservesRequestAndResponse()
{
var request = new HttpRequestMessageWrapper(
new HttpRequestMessage(HttpMethod.Put, "https://management.azure.com/test"),
"{}");
var response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.Conflict),
"{}");
var ex = new ErrorResponseException("Conflict")
{
Request = request,
Response = response,
Body = new ErrorResponse(new ErrorDetail(code: "X", message: "Y"))
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Request.Should().Be(request);
result.Response.Should().Be(response);
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithParseableResponseContent_FormatsCodeAndDetail()
{
var ex = new ErrorResponseException("Operation returned an invalid status code 'NotFound'")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.NotFound),
"{\"error\":{\"code\":\"SubscriptionNotFound\",\"message\":\"The subscription could not be found.\"}}")
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

// Should produce the same "<orig>. code: detail" shape as the structured-body branch,
// not dump the raw JSON blob.
result.Message.Should().Contain("SubscriptionNotFound: The subscription could not be found.");
result.Message.Should().NotContain("Response: {");
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithMalformedResponseContent_FallsBackToRawContent()
{
const string malformed = "this is not json {";
var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest),
malformed)
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Message.Should().Contain("Operation returned an invalid status code 'BadRequest'");
// Short content is embedded as-is (after whitespace collapsing) in the fallback.
result.Message.Should().Contain($"Response: {malformed}");
// Full body remains accessible on the wrapped Response for debugging.
result.Response.Content.Should().Be(malformed);
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithJsonMissingErrorObject_FallsBackToRawContent()
{
const string content = "{\"unrelated\":\"value\"}";
var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest),
content)
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Message.Should().Contain($"Response: {content}");
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithLargeUnparseableContent_TruncatesInMessageButPreservesFullBody()
{
// 1500 chars, well over the 500-char display limit, with embedded newlines.
var largeContent = new string('a', 700) + "\n\n" + new string('b', 800);
var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest),
largeContent)
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Message.Should().Contain("... (truncated)");
result.Message.Should().NotContain("\n\n"); // whitespace collapsed
result.Message.Length.Should().BeLessThan(largeContent.Length);
// Full body must remain available on the wrapped response for debugging.
result.Response.Content.Should().Be(largeContent);
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithBothBodyAndResponseContent_PrefersStructuredBody()
{
var ex = new ErrorResponseException("Operation returned an invalid status code 'Conflict'")
{
Body = new ErrorResponse(new ErrorDetail(code: "BodyCode", message: "Body message.")),
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.Conflict),
"{\"error\":{\"code\":\"ResponseCode\",\"message\":\"Response message.\"}}")
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Message.Should().Contain("BodyCode");
result.Message.Should().Contain("Body message.");
result.Message.Should().NotContain("ResponseCode");
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithCodeOnlyInResponseContent_OmitsTrailingColon()
{
var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest),
"{\"error\":{\"code\":\"OnlyCode\"}}")
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Message.Should().EndWith("OnlyCode");
result.Message.Should().NotContain("OnlyCode:");
}

[Fact]
[Trait(Category.AcceptanceType, Category.CheckIn)]
public void CreateDescriptiveException_WithMessageOnlyInResponseContent_OmitsLeadingColon()
{
var ex = new ErrorResponseException("Operation returned an invalid status code 'BadRequest'")
{
Response = new HttpResponseMessageWrapper(
new HttpResponseMessage(System.Net.HttpStatusCode.BadRequest),
"{\"error\":{\"message\":\"Only the message was provided.\"}}")
};

var result = AuthorizationErrorResponseExceptionHelper.CreateDescriptiveException(ex);

result.Message.Should().EndWith("Only the message was provided.");
result.Message.Should().NotContain(": Only the message");
}
}
}
1 change: 1 addition & 0 deletions src/Resources/Resources/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
-->

## Upcoming Release
* Improved error messages for role assignment and role definition operations to include the underlying service error code and message instead of just the HTTP status code. [#19605] [#19374]
* Added `-PrincipalId` and `-PrincipalType` parameters to `New-AzDenyAssignment` to support per-principal deny assignments targeting a specific User or ServicePrincipal, in addition to the existing Everyone mode.
* Added `New-AzDenyAssignment` cmdlet for creating user-assigned deny assignments using the `2024-07-01-preview` API. Deny assignments allow denying specific write, delete, and action operations to all principals at a given scope while excluding specified principals.
* Added `Remove-AzDenyAssignment` cmdlet for removing user-assigned deny assignments by ID, name and scope, or pipeline input.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// ----------------------------------------------------------------------------------
//
// Copyright Microsoft Corporation
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ----------------------------------------------------------------------------------

using Microsoft.Azure.Commands.Common.Exceptions;
using Microsoft.Azure.Management.Authorization.Models;
using Newtonsoft.Json.Linq;
using System;
using System.Text.RegularExpressions;

namespace Microsoft.Azure.Commands.Resources.Helper
{
/// <summary>
/// Helper class to convert <see cref="ErrorResponseException"/> from the Authorization SDK
/// into <see cref="AzPSCloudException"/> with descriptive error messages.
/// The SDK-generated <see cref="ErrorResponseException"/> only contains the HTTP status code
/// in its message; the actual service error details live on <c>ex.Body.Error</c> or
/// <c>ex.Response.Content</c>. This helper surfaces those details to the user.
/// </summary>
internal static class AuthorizationErrorResponseExceptionHelper
{
// Telemetry-safe placeholder used when no service-supplied error code is available.
// Avoids any risk of leaking PII via ex.Message if the SDK template ever changes.
private const string UnknownErrorCode = "UnknownAuthorizationError";

// Maximum number of characters of raw response content embedded in the user-facing
// exception message. The full body remains available on ex.Response.Content for debugging.
private const int MaxRawContentLength = 500;

/// <summary>
/// Creates an <see cref="AzPSCloudException"/> from an <see cref="ErrorResponseException"/>,
/// extracting the service error details from either the structured Body or the raw response content.
/// </summary>
/// <param name="ex">The original <see cref="ErrorResponseException"/>.</param>
/// <returns>An <see cref="AzPSCloudException"/> with the descriptive error message.</returns>
internal static AzPSCloudException CreateDescriptiveException(ErrorResponseException ex)
{
string message;
string desensitizedMessage;

if (ex.Body?.Error != null)
{
message = $"{ex.Message}. {ex.Body.Error.Code}: {ex.Body.Error.Message}";
desensitizedMessage = ex.Body.Error.Code;
}
else if (!string.IsNullOrEmpty(ex.Response?.Content))
{
(message, desensitizedMessage) = ParseErrorContent(ex.Message, ex.Response.Content);
}
else
{
message = ex.Message;
desensitizedMessage = UnknownErrorCode;
}

return new AzPSCloudException(message, desensitizedMessage, ex)
{
Request = ex.Request,
Response = ex.Response,
};
}

// Builds the (user-facing message, telemetry-safe code) pair from a raw response body.
// On a successful parse of the standard Azure error JSON shape
// ({ "error": { "code": "...", "message": "..." } }) we surface code+message;
// otherwise we fall back to embedding the raw content with an UnknownErrorCode tag.
private static (string Message, string Desensitized) ParseErrorContent(string original, string content)
{
try
{
var error = JObject.Parse(content)["error"] as JObject;
var code = error?.Value<string>("code");
var detail = error?.Value<string>("message");

if (!string.IsNullOrEmpty(code) || !string.IsNullOrEmpty(detail))
{
var suffix = !string.IsNullOrEmpty(code) && !string.IsNullOrEmpty(detail)
? $"{code}: {detail}"
: (code ?? detail);
return ($"{original}. {suffix}", string.IsNullOrEmpty(code) ? UnknownErrorCode : code);
}
Comment thread
MaddyMicrosoft marked this conversation as resolved.
}
catch (Exception)
{
// Fall through to raw-content fallback.
}

return ($"{original}. Response: {TruncateForDisplay(content)}", UnknownErrorCode);
}

// Collapses runs of whitespace and truncates overly long bodies so a multi-line/large
// service response doesn't flood the console. The full body is still available via
// AzPSCloudException.Response.Content for debugging.
private static string TruncateForDisplay(string content)
{
var collapsed = Regex.Replace(content.Trim(), @"\s+", " ");
return collapsed.Length <= MaxRawContentLength
? collapsed
: collapsed.Substring(0, MaxRawContentLength) + "... (truncated)";
}
}
}
Loading