From d24955b8f1296cd51484b40efe2cc4fe7f07594b Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Mon, 11 Aug 2025 11:16:54 +0300 Subject: [PATCH 01/15] Add PermissionsToExclude property and initialization --- .../Reporting/MinimalPermissionsGuidancePlugin.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs index 59d9ff9b..214fc9d4 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs @@ -33,6 +33,7 @@ public sealed class MinimalPermissionsGuidancePluginReport public sealed class MinimalPermissionsGuidancePluginConfiguration { public string? ApiSpecsFolderPath { get; set; } + public IEnumerable? PermissionsToExclude { get; set; } } public sealed class MinimalPermissionsGuidancePlugin( @@ -67,6 +68,7 @@ public override async Task InitializeAsync(InitArgs e, CancellationToken cancell Enabled = false; throw new InvalidOperationException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); } + InitializePermissionsToExclude(); } public override async Task AfterRecordingStopAsync(RecordingArgs e, CancellationToken cancellationToken) @@ -181,6 +183,15 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); } + private void InitializePermissionsToExclude() + { + var key = nameof(MinimalPermissionsGuidancePluginConfiguration.PermissionsToExclude) + .ToCamelCase(); + + string[] defaultPermissionsToExclude = ["profile", "openid", "offline_access", "email"]; + Configuration.PermissionsToExclude = GetConfigurationValue(key, Configuration.PermissionsToExclude, defaultPermissionsToExclude); + } + private async Task> LoadApiSpecsAsync(string apiSpecsFolderPath, CancellationToken cancellationToken) { var apiDefinitions = new Dictionary(); From d3c3948ab7b87f0b173103571a9c6d3265f45fcb Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Mon, 11 Aug 2025 11:17:53 +0300 Subject: [PATCH 02/15] Move ApiSpecsFolderPath property initialization to dedicated method InitializeApiSpecsFolderPath --- .../MinimalPermissionsGuidancePlugin.cs | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs index 214fc9d4..b15c01be 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs @@ -57,17 +57,7 @@ public override async Task InitializeAsync(InitArgs e, CancellationToken cancell { await base.InitializeAsync(e, cancellationToken); - if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new InvalidOperationException("ApiSpecsFolderPath is required."); - } - Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); - if (!Path.Exists(Configuration.ApiSpecsFolderPath)) - { - Enabled = false; - throw new InvalidOperationException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); - } + InitializeApiSpecsFolderPath(); InitializePermissionsToExclude(); } @@ -183,6 +173,22 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); } + private void InitializeApiSpecsFolderPath() + { + if (string.IsNullOrWhiteSpace(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new InvalidOperationException("ApiSpecsFolderPath is required."); + } + + Configuration.ApiSpecsFolderPath = ProxyUtils.GetFullPath(Configuration.ApiSpecsFolderPath, ProxyConfiguration.ConfigFile); + if (!Path.Exists(Configuration.ApiSpecsFolderPath)) + { + Enabled = false; + throw new InvalidOperationException($"ApiSpecsFolderPath '{Configuration.ApiSpecsFolderPath}' does not exist."); + } + } + private void InitializePermissionsToExclude() { var key = nameof(MinimalPermissionsGuidancePluginConfiguration.PermissionsToExclude) From 3ffb3bae30dbbaaff7e0c493a0d80de55e508e43 Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Mon, 11 Aug 2025 11:25:52 +0300 Subject: [PATCH 03/15] Small code adjustments --- DevProxy.Plugins/Generation/MockGeneratorPlugin.cs | 1 - .../Reporting/GraphMinimalPermissionsGuidancePlugin.cs | 5 ++--- .../GraphMinimalPermissionsGuidancePluginReport.cs | 6 ++---- DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs b/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs index 957e6564..26a73b7c 100644 --- a/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs +++ b/DevProxy.Plugins/Generation/MockGeneratorPlugin.cs @@ -34,7 +34,6 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation Logger.LogInformation("Creating mocks from recorded requests..."); - var methodAndUrlComparer = new MethodAndUrlComparer(); var mocks = new List(); foreach (var request in e.RequestLogs) diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs index 11ce07b9..6714fc63 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs @@ -177,8 +177,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation Logger.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue.\r\n"); - if (Configuration.PermissionsToExclude is not null && - Configuration.PermissionsToExclude.Any()) + if (Configuration.PermissionsToExclude?.Any() == true) { Logger.LogInformation("Excluding the following permissions: {Permissions}", string.Join(", ", Configuration.PermissionsToExclude)); } @@ -391,6 +390,6 @@ private static (string method, string url) GetMethodAndUrl(string message) private static string GetTokenizedUrl(string absoluteUrl) { var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); - return "/" + string.Join("", new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); + return "/" + string.Concat(new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); } } diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePluginReport.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePluginReport.cs index 7f5800a2..fcfafb06 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePluginReport.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePluginReport.cs @@ -62,8 +62,7 @@ void transformPermissionsInfo(GraphMinimalPermissionsInfo permissionsInfo, strin transformPermissionsInfo(ApplicationPermissions, "application"); } - if (ExcludedPermissions is not null && - ExcludedPermissions.Any()) + if (ExcludedPermissions?.Any() == true) { _ = sb.AppendLine("## Excluded permissions") .AppendLine() @@ -112,8 +111,7 @@ void transformPermissionsInfo(GraphMinimalPermissionsInfo permissionsInfo, strin transformPermissionsInfo(ApplicationPermissions, "Application"); } - if (ExcludedPermissions is not null && - ExcludedPermissions.Any()) + if (ExcludedPermissions?.Any() == true) { _ = sb.AppendLine("Excluded: permissions:") .AppendLine() diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs index 24b3aac1..d7e056d8 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs @@ -226,6 +226,6 @@ private static (string method, string url) GetMethodAndUrl(string message) private static string GetTokenizedUrl(string absoluteUrl) { var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); - return "/" + string.Join("", new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); + return "/" + string.Concat(new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); } } From 1cda8dd5671f743e0227a599affb4c27ca9e7d08 Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Tue, 12 Aug 2025 17:19:48 +0300 Subject: [PATCH 04/15] Move GetMethodAndUrl to common convert method ToMethodAndUrl --- .../GraphMinimalPermissionsGuidancePlugin.cs | 12 +---------- .../GraphMinimalPermissionsPlugin.cs | 12 +---------- DevProxy.Plugins/Utils/MethodAndUrlUtils.cs | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+), 22 deletions(-) create mode 100644 DevProxy.Plugins/Utils/MethodAndUrlUtils.cs diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs index 6714fc63..f2b20aeb 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs @@ -94,7 +94,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } var methodAndUrlString = request.Message; - var methodAndUrl = GetMethodAndUrl(methodAndUrlString); + var methodAndUrl = MethodAndUrlUtils.ToMethodAndUrl(methodAndUrlString); if (methodAndUrl.method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { continue; @@ -377,16 +377,6 @@ private static (GraphPermissionsType type, IEnumerable permissions) GetP } } - private static (string method, string url) GetMethodAndUrl(string message) - { - var info = message.Split(" "); - if (info.Length > 2) - { - info = [info[0], string.Join(" ", info.Skip(1))]; - } - return (method: info[0], url: info[1]); - } - private static string GetTokenizedUrl(string absoluteUrl) { var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs index d7e056d8..6c6baea6 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs @@ -71,7 +71,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } var methodAndUrlString = request.Message; - var methodAndUrl = GetMethodAndUrl(methodAndUrlString); + var methodAndUrl = MethodAndUrlUtils.ToMethodAndUrl(methodAndUrlString); if (methodAndUrl.method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { continue; @@ -213,16 +213,6 @@ private static (string method, string url)[] GetRequestsFromBatch(string batchBo return [.. requests]; } - private static (string method, string url) GetMethodAndUrl(string message) - { - var info = message.Split(" "); - if (info.Length > 2) - { - info = [info[0], string.Join(" ", info.Skip(1))]; - } - return (info[0], info[1]); - } - private static string GetTokenizedUrl(string absoluteUrl) { var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); diff --git a/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs b/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs new file mode 100644 index 00000000..cde935b4 --- /dev/null +++ b/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace DevProxy.Plugins.Utils; + +internal static class MethodAndUrlUtils +{ + public static (string method, string url) ToMethodAndUrl(string methodAndUrlString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(methodAndUrlString, nameof(methodAndUrlString)); + + var info = methodAndUrlString.Split(" "); + if (info.Length > 2) + { + info = [info[0], string.Join(" ", info.Skip(1))]; + } + return (method: info[0], url: info[1]); + } +} \ No newline at end of file From 826d64455aace5fcf591533e37ff27015b365488 Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Wed, 13 Aug 2025 11:07:59 +0300 Subject: [PATCH 05/15] Add MethodAndUrl record instead of tuple and MethodAndUrlComparer --- .../GraphMinimalPermissionsGuidancePlugin.cs | 40 +++++++++---------- .../GraphMinimalPermissionsPlugin.cs | 28 ++++++------- DevProxy.Plugins/Utils/GraphUtils.cs | 8 ++-- .../Utils/MethodAndUrlComparer.cs | 19 --------- DevProxy.Plugins/Utils/MethodAndUrlUtils.cs | 6 ++- 5 files changed, 42 insertions(+), 59 deletions(-) delete mode 100644 DevProxy.Plugins/Utils/MethodAndUrlComparer.cs diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs index f2b20aeb..6e7ce929 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs @@ -77,9 +77,8 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation return; } - var methodAndUrlComparer = new MethodAndUrlComparer(); - var delegatedEndpoints = new List<(string method, string url)>(); - var applicationEndpoints = new List<(string method, string url)>(); + var delegatedEndpoints = new List(); + var applicationEndpoints = new List(); // scope for delegated permissions IEnumerable scopesToEvaluate = []; @@ -95,20 +94,20 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation var methodAndUrlString = request.Message; var methodAndUrl = MethodAndUrlUtils.ToMethodAndUrl(methodAndUrlString); - if (methodAndUrl.method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) + if (methodAndUrl.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { continue; } - if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, methodAndUrl.url)) + if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, methodAndUrl.Url)) { - Logger.LogDebug("URL not matched: {Url}", methodAndUrl.url); + Logger.LogDebug("URL not matched: {Url}", methodAndUrl.Url); continue; } - var requestsFromBatch = Array.Empty<(string method, string url)>(); + var requestsFromBatch = Array.Empty(); - var uri = new Uri(methodAndUrl.url); + var uri = new Uri(methodAndUrl.Url); if (!ProxyUtils.IsGraphUrl(uri)) { continue; @@ -121,7 +120,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } else { - methodAndUrl = (methodAndUrl.method, GetTokenizedUrl(methodAndUrl.url)); + methodAndUrl = new(methodAndUrl.Method, GetTokenizedUrl(methodAndUrl.Url)); } var (type, permissions) = GetPermissionsAndType(request); @@ -162,8 +161,8 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } // Remove duplicates - delegatedEndpoints = [.. delegatedEndpoints.Distinct(methodAndUrlComparer)]; - applicationEndpoints = [.. applicationEndpoints.Distinct(methodAndUrlComparer)]; + delegatedEndpoints = [.. delegatedEndpoints.Distinct()]; + applicationEndpoints = [.. applicationEndpoints.Distinct()]; if (delegatedEndpoints.Count == 0 && applicationEndpoints.Count == 0) { @@ -187,7 +186,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation var delegatedPermissionsInfo = new GraphMinimalPermissionsInfo(); report.DelegatedPermissions = delegatedPermissionsInfo; - Logger.LogInformation("Evaluating delegated permissions for: {Endpoints}", string.Join(", ", delegatedEndpoints.Select(e => $"{e.method} {e.url}"))); + Logger.LogInformation("Evaluating delegated permissions for: {Endpoints}", string.Join(", ", delegatedEndpoints.Select(e => $"{e.Method} {e.Url}"))); await EvaluateMinimalScopesAsync(delegatedEndpoints, scopesToEvaluate, GraphPermissionsType.Delegated, delegatedPermissionsInfo, cancellationToken); } @@ -197,7 +196,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation var applicationPermissionsInfo = new GraphMinimalPermissionsInfo(); report.ApplicationPermissions = applicationPermissionsInfo; - Logger.LogInformation("Evaluating application permissions for: {Endpoints}", string.Join(", ", applicationEndpoints.Select(e => $"{e.method} {e.url}"))); + Logger.LogInformation("Evaluating application permissions for: {Endpoints}", string.Join(", ", applicationEndpoints.Select(e => $"{e.Method} {e.Url}"))); await EvaluateMinimalScopesAsync(applicationEndpoints, rolesToEvaluate, GraphPermissionsType.Application, applicationPermissionsInfo, cancellationToken); } @@ -217,7 +216,7 @@ private void InitializePermissionsToExclude() } private async Task EvaluateMinimalScopesAsync( - IEnumerable<(string method, string url)> endpoints, + IEnumerable endpoints, IEnumerable permissionsFromAccessToken, GraphPermissionsType scopeType, GraphMinimalPermissionsInfo permissionsInfo, @@ -228,12 +227,12 @@ private async Task EvaluateMinimalScopesAsync( throw new InvalidOperationException("GraphUtils is not initialized. Make sure to call InitializeAsync first."); } - var payload = endpoints.Select(e => new GraphRequestInfo { Method = e.method, Url = e.url }); + var payload = endpoints.Select(e => new GraphRequestInfo { Method = e.Method, Url = e.Url }); permissionsInfo.Operations = [.. endpoints.Select(e => new GraphMinimalPermissionsOperationInfo { - Method = e.method, - Endpoint = e.url + Method = e.Method, + Endpoint = e.Url })]; permissionsInfo.PermissionsFromTheToken = permissionsFromAccessToken; @@ -289,9 +288,9 @@ private async Task EvaluateMinimalScopesAsync( } } - private static (string method, string url)[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) + private static MethodAndUrl[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) { - var requests = new List<(string method, string url)>(); + var requests = new List(); if (string.IsNullOrEmpty(batchBody)) { @@ -313,7 +312,8 @@ private static (string method, string url)[] GetRequestsFromBatch(string batchBo var method = request.Method; var url = request.Url; var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; - requests.Add((method, GetTokenizedUrl(absoluteUrl))); + MethodAndUrl methodAndUrl = new(Method: method, Url: GetTokenizedUrl(absoluteUrl)); + requests.Add(methodAndUrl); } catch { } } diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs index 6c6baea6..01f92e7f 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs @@ -60,8 +60,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation return; } - var methodAndUrlComparer = new MethodAndUrlComparer(); - var endpoints = new List<(string method, string url)>(); + var endpoints = new List(); foreach (var request in e.RequestLogs) { @@ -72,18 +71,18 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation var methodAndUrlString = request.Message; var methodAndUrl = MethodAndUrlUtils.ToMethodAndUrl(methodAndUrlString); - if (methodAndUrl.method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) + if (methodAndUrl.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { continue; } - if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, methodAndUrl.url)) + if (!ProxyUtils.MatchesUrlToWatch(UrlsToWatch, methodAndUrl.Url)) { - Logger.LogDebug("URL not matched: {Url}", methodAndUrl.url); + Logger.LogDebug("URL not matched: {Url}", methodAndUrl.Url); continue; } - var uri = new Uri(methodAndUrl.url); + var uri = new Uri(methodAndUrl.Url); if (!ProxyUtils.IsGraphUrl(uri)) { continue; @@ -97,13 +96,13 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } else { - methodAndUrl = (methodAndUrl.method, GetTokenizedUrl(methodAndUrl.url)); + methodAndUrl = new(methodAndUrl.Method, GetTokenizedUrl(methodAndUrl.Url)); endpoints.Add(methodAndUrl); } } // Remove duplicates - endpoints = [.. endpoints.Distinct(methodAndUrlComparer)]; + endpoints = [.. endpoints.Distinct()]; if (endpoints.Count == 0) { @@ -111,7 +110,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation return; } - Logger.LogInformation("Retrieving minimal permissions for:\r\n{Endpoints}\r\n", string.Join(Environment.NewLine, endpoints.Select(e => $"- {e.method} {e.url}"))); + Logger.LogInformation("Retrieving minimal permissions for:\r\n{Endpoints}\r\n", string.Join(Environment.NewLine, endpoints.Select(e => $"- {e.Method} {e.Url}"))); Logger.LogWarning("This plugin is in preview and may not return the correct results.\r\nPlease review the permissions and test your app before using them in production.\r\nIf you have any feedback, please open an issue at https://aka.ms/devproxy/issue.\r\n"); @@ -125,7 +124,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } private async Task DetermineMinimalScopesAsync( - IEnumerable<(string method, string url)> endpoints, + IEnumerable endpoints, CancellationToken cancellationToken) { if (_graphUtils is null) @@ -133,7 +132,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation throw new InvalidOperationException("GraphUtils is not initialized. Make sure to call InitializeAsync first."); } - var payload = endpoints.Select(e => new GraphRequestInfo { Method = e.method, Url = e.url }); + var payload = endpoints.Select(e => new GraphRequestInfo { Method = e.Method, Url = e.Url }); try { @@ -179,9 +178,9 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } } - private static (string method, string url)[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) + private static MethodAndUrl[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) { - var requests = new List<(string, string)>(); + var requests = new List(); if (string.IsNullOrEmpty(batchBody)) { @@ -203,7 +202,8 @@ private static (string method, string url)[] GetRequestsFromBatch(string batchBo var method = request.Method; var url = request.Url; var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; - requests.Add((method, GetTokenizedUrl(absoluteUrl))); + MethodAndUrl methodAndUrl = new(Method: method, Url: GetTokenizedUrl(absoluteUrl)); + requests.Add(methodAndUrl); } catch { } } diff --git a/DevProxy.Plugins/Utils/GraphUtils.cs b/DevProxy.Plugins/Utils/GraphUtils.cs index 27ef95b6..3fef797b 100644 --- a/DevProxy.Plugins/Utils/GraphUtils.cs +++ b/DevProxy.Plugins/Utils/GraphUtils.cs @@ -47,9 +47,9 @@ internal static string GetScopeTypeString(GraphPermissionsType type) }; } - internal async Task> UpdateUserScopesAsync(IEnumerable minimalScopes, IEnumerable<(string method, string url)> endpoints, GraphPermissionsType permissionsType) + internal async Task> UpdateUserScopesAsync(IEnumerable minimalScopes, IEnumerable endpoints, GraphPermissionsType permissionsType) { - var userEndpoints = endpoints.Where(e => e.url.Contains("/users/{", StringComparison.OrdinalIgnoreCase)); + var userEndpoints = endpoints.Where(e => e.Url.Contains("/users/{", StringComparison.OrdinalIgnoreCase)); if (!userEndpoints.Any()) { return minimalScopes; @@ -60,8 +60,8 @@ internal async Task> UpdateUserScopesAsync(IEnumerable { - _logger.LogDebug("Getting permissions for {Method} {Url}", e.method, e.url); - return $"{url}&requesturl={e.url}&method={e.method}"; + _logger.LogDebug("Getting permissions for {Method} {Url}", e.Method, e.Url); + return $"{url}&requesturl={e.Url}&method={e.Method}"; }); var tasks = urls.Select(u => { diff --git a/DevProxy.Plugins/Utils/MethodAndUrlComparer.cs b/DevProxy.Plugins/Utils/MethodAndUrlComparer.cs deleted file mode 100644 index abf10515..00000000 --- a/DevProxy.Plugins/Utils/MethodAndUrlComparer.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace DevProxy.Plugins.Utils; - -internal sealed class MethodAndUrlComparer : IEqualityComparer<(string method, string url)> -{ - public bool Equals((string method, string url) x, (string method, string url) y) => - x.method == y.method && x.url == y.url; - - public int GetHashCode((string method, string url) obj) - { - var methodHashCode = obj.method.GetHashCode(StringComparison.OrdinalIgnoreCase); - var urlHashCode = obj.url.GetHashCode(StringComparison.OrdinalIgnoreCase); - - return methodHashCode ^ urlHashCode; - } -} \ No newline at end of file diff --git a/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs b/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs index cde935b4..483b8fb5 100644 --- a/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs +++ b/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs @@ -4,9 +4,11 @@ namespace DevProxy.Plugins.Utils; +internal readonly record struct MethodAndUrl(string Method, string Url); + internal static class MethodAndUrlUtils { - public static (string method, string url) ToMethodAndUrl(string methodAndUrlString) + public static MethodAndUrl ToMethodAndUrl(string methodAndUrlString) { ArgumentException.ThrowIfNullOrWhiteSpace(methodAndUrlString, nameof(methodAndUrlString)); @@ -15,6 +17,6 @@ public static (string method, string url) ToMethodAndUrl(string methodAndUrlStri { info = [info[0], string.Join(" ", info.Skip(1))]; } - return (method: info[0], url: info[1]); + return new(Method: info[0], Url: info[1]); } } \ No newline at end of file From ea0c49b8da605bd0a3cf22a55309046604dc7368 Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Wed, 13 Aug 2025 11:15:02 +0300 Subject: [PATCH 06/15] Move MethodAndUrlUtils to GraphUtils unit --- .../GraphMinimalPermissionsGuidancePlugin.cs | 2 +- .../GraphMinimalPermissionsPlugin.cs | 2 +- DevProxy.Plugins/Utils/GraphUtils.cs | 15 +++++++++++++ DevProxy.Plugins/Utils/MethodAndUrlUtils.cs | 22 ------------------- 4 files changed, 17 insertions(+), 24 deletions(-) delete mode 100644 DevProxy.Plugins/Utils/MethodAndUrlUtils.cs diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs index 6e7ce929..23bd84e0 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs @@ -93,7 +93,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } var methodAndUrlString = request.Message; - var methodAndUrl = MethodAndUrlUtils.ToMethodAndUrl(methodAndUrlString); + var methodAndUrl = GraphUtils.GetMethodAndUrl(methodAndUrlString); if (methodAndUrl.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { continue; diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs index 01f92e7f..71415e88 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs @@ -70,7 +70,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } var methodAndUrlString = request.Message; - var methodAndUrl = MethodAndUrlUtils.ToMethodAndUrl(methodAndUrlString); + var methodAndUrl = GraphUtils.GetMethodAndUrl(methodAndUrlString); if (methodAndUrl.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { continue; diff --git a/DevProxy.Plugins/Utils/GraphUtils.cs b/DevProxy.Plugins/Utils/GraphUtils.cs index 3fef797b..0276f56b 100644 --- a/DevProxy.Plugins/Utils/GraphUtils.cs +++ b/DevProxy.Plugins/Utils/GraphUtils.cs @@ -9,6 +9,8 @@ namespace DevProxy.Plugins.Utils; +internal readonly record struct MethodAndUrl(string Method, string Url); + sealed class GraphUtils( HttpClient httpClient, ILogger logger) @@ -96,4 +98,17 @@ internal async Task> UpdateUserScopesAsync(IEnumerable 2) + { + info = [info[0], string.Join(" ", info.Skip(1))]; + } + return new(Method: info[0], Url: info[1]); + } + } \ No newline at end of file diff --git a/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs b/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs deleted file mode 100644 index 483b8fb5..00000000 --- a/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -namespace DevProxy.Plugins.Utils; - -internal readonly record struct MethodAndUrl(string Method, string Url); - -internal static class MethodAndUrlUtils -{ - public static MethodAndUrl ToMethodAndUrl(string methodAndUrlString) - { - ArgumentException.ThrowIfNullOrWhiteSpace(methodAndUrlString, nameof(methodAndUrlString)); - - var info = methodAndUrlString.Split(" "); - if (info.Length > 2) - { - info = [info[0], string.Join(" ", info.Skip(1))]; - } - return new(Method: info[0], Url: info[1]); - } -} \ No newline at end of file From 1905de168ca0ec3c6fb35fe57f85b47a65fcc8a6 Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Wed, 13 Aug 2025 11:21:08 +0300 Subject: [PATCH 07/15] Move common GetRequestsFromBatch and GetTokenizedUrl methods to GraphUtils --- .../GraphMinimalPermissionsGuidancePlugin.cs | 45 +------------------ .../GraphMinimalPermissionsPlugin.cs | 45 +------------------ DevProxy.Plugins/Utils/GraphUtils.cs | 45 ++++++++++++++++++- 3 files changed, 48 insertions(+), 87 deletions(-) diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs index 23bd84e0..b736cf5f 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs @@ -116,11 +116,11 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation if (ProxyUtils.IsGraphBatchUrl(uri)) { var graphVersion = ProxyUtils.IsGraphBetaUrl(uri) ? "beta" : "v1.0"; - requestsFromBatch = GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); + requestsFromBatch = GraphUtils.GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); } else { - methodAndUrl = new(methodAndUrl.Method, GetTokenizedUrl(methodAndUrl.Url)); + methodAndUrl = new(methodAndUrl.Method, GraphUtils.GetTokenizedUrl(methodAndUrl.Url)); } var (type, permissions) = GetPermissionsAndType(request); @@ -288,41 +288,6 @@ private async Task EvaluateMinimalScopesAsync( } } - private static MethodAndUrl[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) - { - var requests = new List(); - - if (string.IsNullOrEmpty(batchBody)) - { - return [.. requests]; - } - - try - { - var batch = JsonSerializer.Deserialize(batchBody, ProxyUtils.JsonSerializerOptions); - if (batch == null) - { - return [.. requests]; - } - - foreach (var request in batch.Requests) - { - try - { - var method = request.Method; - var url = request.Url; - var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; - MethodAndUrl methodAndUrl = new(Method: method, Url: GetTokenizedUrl(absoluteUrl)); - requests.Add(methodAndUrl); - } - catch { } - } - } - catch { } - - return [.. requests]; - } - /// /// Returns permissions and type (delegated or application) from the access token /// used on the request. @@ -376,10 +341,4 @@ private static (GraphPermissionsType type, IEnumerable permissions) GetP return (GraphPermissionsType.Application, []); } } - - private static string GetTokenizedUrl(string absoluteUrl) - { - var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); - return "/" + string.Concat(new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); - } } diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs index 71415e88..da66aff3 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs @@ -91,12 +91,12 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation if (ProxyUtils.IsGraphBatchUrl(uri)) { var graphVersion = ProxyUtils.IsGraphBetaUrl(uri) ? "beta" : "v1.0"; - var requestsFromBatch = GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); + var requestsFromBatch = GraphUtils.GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); endpoints.AddRange(requestsFromBatch); } else { - methodAndUrl = new(methodAndUrl.Method, GetTokenizedUrl(methodAndUrl.Url)); + methodAndUrl = new(methodAndUrl.Method, GraphUtils.GetTokenizedUrl(methodAndUrl.Url)); endpoints.Add(methodAndUrl); } } @@ -177,45 +177,4 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation return null; } } - - private static MethodAndUrl[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) - { - var requests = new List(); - - if (string.IsNullOrEmpty(batchBody)) - { - return [.. requests]; - } - - try - { - var batch = JsonSerializer.Deserialize(batchBody, ProxyUtils.JsonSerializerOptions); - if (batch == null) - { - return [.. requests]; - } - - foreach (var request in batch.Requests) - { - try - { - var method = request.Method; - var url = request.Url; - var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; - MethodAndUrl methodAndUrl = new(Method: method, Url: GetTokenizedUrl(absoluteUrl)); - requests.Add(methodAndUrl); - } - catch { } - } - } - catch { } - - return [.. requests]; - } - - private static string GetTokenizedUrl(string absoluteUrl) - { - var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); - return "/" + string.Concat(new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); - } } diff --git a/DevProxy.Plugins/Utils/GraphUtils.cs b/DevProxy.Plugins/Utils/GraphUtils.cs index 0276f56b..e4c89bcb 100644 --- a/DevProxy.Plugins/Utils/GraphUtils.cs +++ b/DevProxy.Plugins/Utils/GraphUtils.cs @@ -2,9 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using DevProxy.Abstractions.Models; +using DevProxy.Abstractions.Utils; using DevProxy.Plugins.Models; using Microsoft.Extensions.Logging; using System.Net.Http.Json; +using System.Text.Json; using Titanium.Web.Proxy.Http; namespace DevProxy.Plugins.Utils; @@ -101,7 +104,7 @@ internal async Task> UpdateUserScopesAsync(IEnumerable 2) @@ -111,4 +114,44 @@ public static MethodAndUrl GetMethodAndUrl(string methodAndUrlString) return new(Method: info[0], Url: info[1]); } + public static string GetTokenizedUrl(string absoluteUrl) + { + var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); + return "/" + string.Concat(new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString)); + } + + public static MethodAndUrl[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName) + { + var requests = new List(); + + if (string.IsNullOrEmpty(batchBody)) + { + return [.. requests]; + } + + try + { + var batch = JsonSerializer.Deserialize(batchBody, ProxyUtils.JsonSerializerOptions); + if (batch == null) + { + return [.. requests]; + } + + foreach (var request in batch.Requests) + { + try + { + var method = request.Method; + var url = request.Url; + var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; + MethodAndUrl methodAndUrl = new(Method: method, Url: GetTokenizedUrl(absoluteUrl)); + requests.Add(methodAndUrl); + } + catch { } + } + } + catch { } + + return [.. requests]; + } } \ No newline at end of file From 87f20482d0340b14c3f9a5b2ec4a2e71adaca259 Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Thu, 14 Aug 2025 17:14:54 +0300 Subject: [PATCH 08/15] Exclude permissions from excessive list --- .../Reporting/MinimalPermissionsGuidancePlugin.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs index b15c01be..ef273349 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs @@ -111,7 +111,10 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation .Distinct()], TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], MinimalPermissions = minimalPermissions.MinimalScopes, - ExcessivePermissions = [.. minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes)], + ExcessivePermissions = [.. minimalPermissions.TokenPermissions + .Except(Configuration.PermissionsToExclude ?? []) + .Except(minimalPermissions.MinimalScopes) + ], UsesMinimalPermissions = !minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).Any() }; results.Add(result); From b8442f4f65a80b0bbd74b7e9935ab66ecd9431e0 Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Thu, 14 Aug 2025 17:15:29 +0300 Subject: [PATCH 09/15] Add ExcludedPermissions property to report class --- .../Reporting/MinimalPermissionsGuidancePlugin.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs index ef273349..6577ccf5 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs @@ -28,6 +28,7 @@ public sealed class MinimalPermissionsGuidancePluginReport public required IEnumerable Errors { get; init; } public required IEnumerable Results { get; init; } public required IEnumerable UnmatchedRequests { get; init; } + public IEnumerable? ExcludedPermissions { get; set; } } public sealed class MinimalPermissionsGuidancePluginConfiguration @@ -168,7 +169,8 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation { Results = [.. results], UnmatchedRequests = [.. unmatchedRequests], - Errors = [.. errors] + Errors = [.. errors], + ExcludedPermissions = Configuration.PermissionsToExclude }; StoreReport(report, e); From f0698cef9e238becc9fe1c826cdfb008c48d801c Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Thu, 14 Aug 2025 17:16:50 +0300 Subject: [PATCH 10/15] Add logging of what permissions are excluded --- .../Reporting/MinimalPermissionsGuidancePlugin.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs index 6577ccf5..c8aac197 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs @@ -173,6 +173,11 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation ExcludedPermissions = Configuration.PermissionsToExclude }; + if (Configuration.PermissionsToExclude?.Any() == true) + { + Logger.LogInformation("Excluding the following permissions: {Permissions}", string.Join(", ", Configuration.PermissionsToExclude)); + } + StoreReport(report, e); Logger.LogTrace("Left {Name}", nameof(AfterRecordingStopAsync)); From 7517090827267ca0ad45fe85f47df1a089f3c4e4 Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Thu, 14 Aug 2025 17:56:00 +0300 Subject: [PATCH 11/15] Add permissionsToExclude to MinimalPermissionsGuidancePlugin config schema --- .../v0.26.0/minimalpermissionsguidanceplugin.schema.json | 8 ++++++++ .../v0.27.0/minimalpermissionsguidanceplugin.schema.json | 8 ++++++++ .../v0.28.0/minimalpermissionsguidanceplugin.schema.json | 8 ++++++++ .../v0.29.0/minimalpermissionsguidanceplugin.schema.json | 8 ++++++++ .../v0.29.1/minimalpermissionsguidanceplugin.schema.json | 8 ++++++++ .../v0.29.2/minimalpermissionsguidanceplugin.schema.json | 8 ++++++++ .../v1.0.0/minimalpermissionsguidanceplugin.schema.json | 8 ++++++++ .../v1.1.0/minimalpermissionsguidanceplugin.schema.json | 8 ++++++++ 8 files changed, 64 insertions(+) diff --git a/schemas/v0.26.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.26.0/minimalpermissionsguidanceplugin.schema.json index 33b1d3ae..1c248b2d 100644 --- a/schemas/v0.26.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.26.0/minimalpermissionsguidanceplugin.schema.json @@ -8,6 +8,14 @@ }, "apiSpecsFolderPath": { "type": "string" + }, + "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"] } }, "required": [ diff --git a/schemas/v0.27.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.27.0/minimalpermissionsguidanceplugin.schema.json index 67849ad6..d9f54eed 100644 --- a/schemas/v0.27.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.27.0/minimalpermissionsguidanceplugin.schema.json @@ -10,6 +10,14 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." + }, + "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"] } }, "required": [ diff --git a/schemas/v0.28.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.28.0/minimalpermissionsguidanceplugin.schema.json index 67849ad6..d9f54eed 100644 --- a/schemas/v0.28.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.28.0/minimalpermissionsguidanceplugin.schema.json @@ -10,6 +10,14 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." + }, + "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"] } }, "required": [ diff --git a/schemas/v0.29.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.29.0/minimalpermissionsguidanceplugin.schema.json index 67849ad6..d9f54eed 100644 --- a/schemas/v0.29.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.29.0/minimalpermissionsguidanceplugin.schema.json @@ -10,6 +10,14 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." + }, + "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"] } }, "required": [ diff --git a/schemas/v0.29.1/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.29.1/minimalpermissionsguidanceplugin.schema.json index 67849ad6..d9f54eed 100644 --- a/schemas/v0.29.1/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.29.1/minimalpermissionsguidanceplugin.schema.json @@ -10,6 +10,14 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." + }, + "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"] } }, "required": [ diff --git a/schemas/v0.29.2/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.29.2/minimalpermissionsguidanceplugin.schema.json index 67849ad6..d9f54eed 100644 --- a/schemas/v0.29.2/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.29.2/minimalpermissionsguidanceplugin.schema.json @@ -10,6 +10,14 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." + }, + "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"] } }, "required": [ diff --git a/schemas/v1.0.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v1.0.0/minimalpermissionsguidanceplugin.schema.json index 67849ad6..d9f54eed 100644 --- a/schemas/v1.0.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v1.0.0/minimalpermissionsguidanceplugin.schema.json @@ -10,6 +10,14 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." + }, + "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"] } }, "required": [ diff --git a/schemas/v1.1.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v1.1.0/minimalpermissionsguidanceplugin.schema.json index 67849ad6..d9f54eed 100644 --- a/schemas/v1.1.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v1.1.0/minimalpermissionsguidanceplugin.schema.json @@ -10,6 +10,14 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." + }, + "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"] } }, "required": [ From 02eba7f4de461c6cf68fb7cc2a63fbfa39768cdc Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Fri, 15 Aug 2025 10:30:11 +0300 Subject: [PATCH 12/15] Update schema only for v1.1.0 --- .../v0.26.0/minimalpermissionsguidanceplugin.schema.json | 8 -------- .../v0.27.0/minimalpermissionsguidanceplugin.schema.json | 8 -------- .../v0.28.0/minimalpermissionsguidanceplugin.schema.json | 8 -------- .../v0.29.0/minimalpermissionsguidanceplugin.schema.json | 8 -------- .../v0.29.1/minimalpermissionsguidanceplugin.schema.json | 8 -------- .../v0.29.2/minimalpermissionsguidanceplugin.schema.json | 8 -------- .../v1.0.0/minimalpermissionsguidanceplugin.schema.json | 8 -------- 7 files changed, 56 deletions(-) diff --git a/schemas/v0.26.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.26.0/minimalpermissionsguidanceplugin.schema.json index 1c248b2d..33b1d3ae 100644 --- a/schemas/v0.26.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.26.0/minimalpermissionsguidanceplugin.schema.json @@ -8,14 +8,6 @@ }, "apiSpecsFolderPath": { "type": "string" - }, - "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"] } }, "required": [ diff --git a/schemas/v0.27.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.27.0/minimalpermissionsguidanceplugin.schema.json index d9f54eed..67849ad6 100644 --- a/schemas/v0.27.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.27.0/minimalpermissionsguidanceplugin.schema.json @@ -10,14 +10,6 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." - }, - "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"] } }, "required": [ diff --git a/schemas/v0.28.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.28.0/minimalpermissionsguidanceplugin.schema.json index d9f54eed..67849ad6 100644 --- a/schemas/v0.28.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.28.0/minimalpermissionsguidanceplugin.schema.json @@ -10,14 +10,6 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." - }, - "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"] } }, "required": [ diff --git a/schemas/v0.29.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.29.0/minimalpermissionsguidanceplugin.schema.json index d9f54eed..67849ad6 100644 --- a/schemas/v0.29.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.29.0/minimalpermissionsguidanceplugin.schema.json @@ -10,14 +10,6 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." - }, - "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"] } }, "required": [ diff --git a/schemas/v0.29.1/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.29.1/minimalpermissionsguidanceplugin.schema.json index d9f54eed..67849ad6 100644 --- a/schemas/v0.29.1/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.29.1/minimalpermissionsguidanceplugin.schema.json @@ -10,14 +10,6 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." - }, - "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"] } }, "required": [ diff --git a/schemas/v0.29.2/minimalpermissionsguidanceplugin.schema.json b/schemas/v0.29.2/minimalpermissionsguidanceplugin.schema.json index d9f54eed..67849ad6 100644 --- a/schemas/v0.29.2/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v0.29.2/minimalpermissionsguidanceplugin.schema.json @@ -10,14 +10,6 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." - }, - "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"] } }, "required": [ diff --git a/schemas/v1.0.0/minimalpermissionsguidanceplugin.schema.json b/schemas/v1.0.0/minimalpermissionsguidanceplugin.schema.json index d9f54eed..67849ad6 100644 --- a/schemas/v1.0.0/minimalpermissionsguidanceplugin.schema.json +++ b/schemas/v1.0.0/minimalpermissionsguidanceplugin.schema.json @@ -10,14 +10,6 @@ "apiSpecsFolderPath": { "type": "string", "description": "Relative or absolute path to the folder with API specs. Used to compare JWT token permissions against minimal required scopes." - }, - "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"] } }, "required": [ From 65e8cf70a406ae5719b50e8a7f15f167e4a8bd0e Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Fri, 15 Aug 2025 10:36:21 +0300 Subject: [PATCH 13/15] Lower accessibility level to internal for GetMethodAndUrl, GetTokenizedUrl, GetRequestsFromBatch --- DevProxy.Plugins/Utils/GraphUtils.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DevProxy.Plugins/Utils/GraphUtils.cs b/DevProxy.Plugins/Utils/GraphUtils.cs index e4c89bcb..b1e181ce 100644 --- a/DevProxy.Plugins/Utils/GraphUtils.cs +++ b/DevProxy.Plugins/Utils/GraphUtils.cs @@ -102,7 +102,7 @@ internal async Task> UpdateUserScopesAsync(IEnumerable(); From 7555b0cb87613dfa62e0417c146c5433eeec7947 Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Fri, 15 Aug 2025 10:46:05 +0300 Subject: [PATCH 14/15] Move GetMethodAndUrl func out to MethodAndUrlUtils unit --- .../GraphMinimalPermissionsGuidancePlugin.cs | 2 +- .../GraphMinimalPermissionsPlugin.cs | 2 +- DevProxy.Plugins/Utils/GraphUtils.cs | 14 ------------ DevProxy.Plugins/Utils/MethodAndUrlUtils.cs | 22 +++++++++++++++++++ 4 files changed, 24 insertions(+), 16 deletions(-) create mode 100644 DevProxy.Plugins/Utils/MethodAndUrlUtils.cs diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs index b736cf5f..51af899a 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsGuidancePlugin.cs @@ -93,7 +93,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } var methodAndUrlString = request.Message; - var methodAndUrl = GraphUtils.GetMethodAndUrl(methodAndUrlString); + var methodAndUrl = MethodAndUrlUtils.GetMethodAndUrl(methodAndUrlString); if (methodAndUrl.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { continue; diff --git a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs index da66aff3..22e43bf4 100644 --- a/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs +++ b/DevProxy.Plugins/Reporting/GraphMinimalPermissionsPlugin.cs @@ -70,7 +70,7 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation } var methodAndUrlString = request.Message; - var methodAndUrl = GraphUtils.GetMethodAndUrl(methodAndUrlString); + var methodAndUrl = MethodAndUrlUtils.GetMethodAndUrl(methodAndUrlString); if (methodAndUrl.Method.Equals("OPTIONS", StringComparison.OrdinalIgnoreCase)) { continue; diff --git a/DevProxy.Plugins/Utils/GraphUtils.cs b/DevProxy.Plugins/Utils/GraphUtils.cs index b1e181ce..986f708e 100644 --- a/DevProxy.Plugins/Utils/GraphUtils.cs +++ b/DevProxy.Plugins/Utils/GraphUtils.cs @@ -12,8 +12,6 @@ namespace DevProxy.Plugins.Utils; -internal readonly record struct MethodAndUrl(string Method, string Url); - sealed class GraphUtils( HttpClient httpClient, ILogger logger) @@ -102,18 +100,6 @@ internal async Task> UpdateUserScopesAsync(IEnumerable 2) - { - info = [info[0], string.Join(" ", info.Skip(1))]; - } - return new(Method: info[0], Url: info[1]); - } - internal static string GetTokenizedUrl(string absoluteUrl) { var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl); diff --git a/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs b/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs new file mode 100644 index 00000000..b4412638 --- /dev/null +++ b/DevProxy.Plugins/Utils/MethodAndUrlUtils.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace DevProxy.Plugins.Utils; + +internal readonly record struct MethodAndUrl(string Method, string Url); + +static class MethodAndUrlUtils +{ + internal static MethodAndUrl GetMethodAndUrl(string methodAndUrlString) + { + ArgumentException.ThrowIfNullOrWhiteSpace(methodAndUrlString); + + var info = methodAndUrlString.Split(" "); + if (info.Length > 2) + { + info = [info[0], string.Join(" ", info.Skip(1))]; + } + return new(Method: info[0], Url: info[1]); + } +} \ No newline at end of file From 63291d94b6eb35a527d7b400526897c57308649b Mon Sep 17 00:00:00 2001 From: Artem Azaraev Date: Fri, 15 Aug 2025 17:21:53 +0300 Subject: [PATCH 15/15] Evaluate excessivePermissions and properly initiate flag UsesMinimalPermissions --- .../Reporting/MinimalPermissionsGuidancePlugin.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs index c8aac197..64770502 100644 --- a/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs +++ b/DevProxy.Plugins/Reporting/MinimalPermissionsGuidancePlugin.cs @@ -103,6 +103,11 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation { var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger); + IEnumerable excessivePermissions = [.. minimalPermissions.TokenPermissions + .Except(Configuration.PermissionsToExclude ?? []) + .Except(minimalPermissions.MinimalScopes) + ]; + var result = new MinimalPermissionsGuidancePluginReportApiResult { ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Any() ? @@ -112,11 +117,8 @@ public override async Task AfterRecordingStopAsync(RecordingArgs e, Cancellation .Distinct()], TokenPermissions = [.. minimalPermissions.TokenPermissions.Distinct()], MinimalPermissions = minimalPermissions.MinimalScopes, - ExcessivePermissions = [.. minimalPermissions.TokenPermissions - .Except(Configuration.PermissionsToExclude ?? []) - .Except(minimalPermissions.MinimalScopes) - ], - UsesMinimalPermissions = !minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).Any() + ExcessivePermissions = excessivePermissions, + UsesMinimalPermissions = !excessivePermissions.Any() }; results.Add(result);