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
40 changes: 39 additions & 1 deletion DevProxy.Integration.Tests/ReportingPluginsIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ namespace DevProxy.Integration.Tests;
///
/// NOT hermetic (deferred): GraphMinimalPermissions(+Guidance) call Microsoft Graph permission
/// endpoints; MinimalCsomPermissions needs the shipped CSOM types definition + CSOM bodies;
/// ApiCenter* require Azure API Center. Those are integration-with-live-backend (Bucket 3).
/// ApiCenter* require Azure API Center. Those are integration-with-live-backend (Bucket 3); only
/// their backend-independent logic (permission exclusion, report rendering) is covered here.
/// </summary>
public sealed class ReportingPluginsIntegrationTests
{
Expand Down Expand Up @@ -150,4 +151,41 @@ await File.WriteAllTextAsync(Path.Combine(dir.FullName, "contoso.json"), """
dir.Delete(recursive: true);
}
}

[Fact]
public void ApiCenterMinimalPermissions_ExcludesOidcScopesFromExcessivePermissions()
{
var excessive = ApiCenterMinimalPermissionsPlugin.GetExcessivePermissions(
tokenPermissions: ["User.Read", "User.Write", "openid", "profile", "offline_access", "email"],
minimalPermissions: ["User.Read"],
permissionsToExclude: ["profile", "openid", "offline_access", "email"]);

Assert.Equal(["User.Write"], excessive);
}

[Fact]
public void ApiCenterMinimalPermissions_WithoutExclusionsReportsAllExcessivePermissions()
{
var excessive = ApiCenterMinimalPermissionsPlugin.GetExcessivePermissions(
tokenPermissions: ["User.Read", "openid"],
minimalPermissions: ["User.Read"],
permissionsToExclude: null);

Assert.Equal(["openid"], excessive);
}

[Fact]
public void ApiCenterMinimalPermissionsReport_ListsExcludedPermissions()
{
var report = new ApiCenterMinimalPermissionsPluginReport
{
Results = [],
UnmatchedRequests = [],
Errors = [],
ExcludedPermissions = ["openid", "profile"],
};

Assert.Contains("- `openid`", report.ToMarkdown(), StringComparison.Ordinal);
Assert.Contains("openid, profile", report.ToPlainText(), StringComparison.Ordinal);
}
}
38 changes: 35 additions & 3 deletions DevProxy.Plugins/Reporting/ApiCenterMinimalPermissionsPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ namespace DevProxy.Plugins.Reporting;

public sealed class ApiCenterMinimalPermissionsPluginConfiguration
{
public IEnumerable<string>? PermissionsToExclude { get; set; }
public string ResourceGroupName { get; set; } = "";
public string ServiceName { get; set; } = "";
public string SubscriptionId { get; set; } = "";
Expand Down Expand Up @@ -50,6 +51,8 @@ public override async Task InitializeAsync(InitArgs e, CancellationToken cancell

await base.InitializeAsync(e, cancellationToken);

InitializePermissionsToExclude();

try
{
_apiCenterClient = ActivatorUtilities.CreateInstance<ApiCenterClient>(e.ServiceProvider,
Expand Down Expand Up @@ -132,6 +135,11 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation

var minimalPermissions = CheckMinimalPermissions(requests, apiDefinition);

var excessivePermissions = GetExcessivePermissions(
minimalPermissions.TokenPermissions,
minimalPermissions.MinimalScopes,
Configuration.PermissionsToExclude);

var api = _apis.FindApiByDefinition(apiDefinition, Logger);
var result = new ApiCenterMinimalPermissionsPluginReportApiResult
{
Expand All @@ -143,8 +151,8 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation
.Distinct()],
TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()],
MinimalPermissions = minimalPermissions.MinimalScopes,
ExcessivePermissions = [.. minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes)],
UsesMinimalPermissions = !minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).Any(),
ExcessivePermissions = excessivePermissions,
UsesMinimalPermissions = !excessivePermissions.Any(),
SchemeName = Configuration.SchemeName
};
results.Add(result);
Expand Down Expand Up @@ -241,14 +249,38 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation
{
Results = [.. results],
UnmatchedRequests = [.. unmatchedRequests],
Errors = [.. errors]
Errors = [.. errors],
ExcludedPermissions = Configuration.PermissionsToExclude
};

if (Configuration.PermissionsToExclude?.Any() == true &&
Logger.IsEnabled(LogLevel.Information))
{
Logger.LogInformation("Excluding the following permissions: {Permissions}", string.Join(", ", Configuration.PermissionsToExclude));
}

StoreReport(report, e);

Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync));
}

internal static IEnumerable<string> GetExcessivePermissions(
IEnumerable<string> tokenPermissions,
IEnumerable<string> minimalPermissions,
IEnumerable<string>? permissionsToExclude) =>
[.. tokenPermissions
.Except(permissionsToExclude ?? [])
.Except(minimalPermissions)];

private void InitializePermissionsToExclude()
{
var key = nameof(ApiCenterMinimalPermissionsPluginConfiguration.PermissionsToExclude)
.ToCamelCase();

string[] defaultPermissionsToExclude = ["profile", "openid", "offline_access", "email"];
Configuration.PermissionsToExclude = GetConfigurationValue(key, Configuration.PermissionsToExclude, defaultPermissionsToExclude);
}

private ApiPermissionsInfo CheckMinimalPermissions(IEnumerable<RequestLog> requests, ApiDefinition apiDefinition)
{
if (Logger.IsEnabled(LogLevel.Information))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public sealed class ApiCenterMinimalPermissionsPluginReport : IMarkdownReport, I
public required IEnumerable<ApiPermissionError> Errors { get; init; }
public required IEnumerable<ApiCenterMinimalPermissionsPluginReportApiResult> Results { get; init; }
public required IEnumerable<string> UnmatchedRequests { get; init; }
public IEnumerable<string>? ExcludedPermissions { get; init; }

public string ToMarkdown()
{
Expand Down Expand Up @@ -100,7 +101,17 @@ public string ToMarkdown()
.OrderBy(o => o.Request)
.Select(e => $"- `{e.Request}`: {e.Error}")).AppendLine()
.AppendLine()
: sb.AppendLine("No errors occurred.");
: sb.AppendLine("No errors occurred.")
.AppendLine();

if (ExcludedPermissions?.Any() == true)
{
_ = sb.AppendLine("## 🚫 Excluded permissions")
.AppendLine()
.AppendLine("The following permissions were excluded from the analysis:")
.AppendLine()
.AppendJoin(Environment.NewLine, ExcludedPermissions.Select(p => $"- `{p}`")).AppendLine();
}

return sb.ToString();
}
Expand Down Expand Up @@ -166,7 +177,17 @@ public string ToMarkdown()
.OrderBy(o => o.Request)
.Select(e => $"- `{e.Request}`: {e.Error}")).AppendLine()
.AppendLine()
: sb.AppendLine("No errors occurred.");
: sb.AppendLine("No errors occurred.")
.AppendLine();

if (ExcludedPermissions?.Any() == true)
{
_ = sb.AppendLine("EXCLUDED PERMISSIONS")
.AppendLine()
.AppendLine("The following permissions were excluded from the analysis:")
.AppendLine()
.AppendJoin(", ", ExcludedPermissions).AppendLine();
}

return sb.ToString();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
"type": "string",
"description": "The JSON schema reference for validation."
},
"permissionsToExclude": {
"type": "array",
"items": {
"type": "string"
},
"description": "The scopes to ignore and not include in the report. Default: ['profile', 'openid', 'offline_access', 'email'].",
"default": ["profile", "openid", "offline_access", "email"]
},
"resourceGroupName": {
"type": "string",
"description": "Name of the resource group where the Azure API Center is located."
Expand Down
Loading