Skip to content

Commit 7c2ca67

Browse files
Renames MinimalPermissionsPlugin to MinimalPermissionsGuidancePlugin. Closes #1058 (#1059)
1 parent ea05c23 commit 7c2ca67

File tree

2 files changed

+258
-23
lines changed

2 files changed

+258
-23
lines changed
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using DevProxy.Abstractions;
6+
using DevProxy.Plugins.MinimalPermissions;
7+
using Microsoft.Extensions.Configuration;
8+
using Microsoft.Extensions.Logging;
9+
using Microsoft.OpenApi.Models;
10+
using Microsoft.OpenApi.Readers;
11+
12+
namespace DevProxy.Plugins.RequestLogs;
13+
14+
public class MinimalPermissionsGuidancePluginReportApiResult
15+
{
16+
public required string ApiName { get; init; }
17+
public required string[] Requests { get; init; }
18+
public required string[] TokenPermissions { get; init; }
19+
public required string[] MinimalPermissions { get; init; }
20+
public required string[] ExcessivePermissions { get; init; }
21+
public required bool UsesMinimalPermissions { get; init; }
22+
}
23+
24+
public class MinimalPermissionsGuidancePluginReport
25+
{
26+
public required MinimalPermissionsGuidancePluginReportApiResult[] Results { get; init; }
27+
public required string[] UnmatchedRequests { get; init; }
28+
public required ApiPermissionError[] Errors { get; init; }
29+
}
30+
31+
public class MinimalPermissionsGuidancePluginConfiguration
32+
{
33+
public string? ApiSpecsFolderPath { get; set; }
34+
}
35+
36+
public class MinimalPermissionsGuidancePlugin(IPluginEvents pluginEvents, IProxyContext context, ILogger logger, ISet<UrlToWatch> urlsToWatch, IConfigurationSection? configSection = null) : BaseReportingPlugin(pluginEvents, context, logger, urlsToWatch, configSection)
37+
{
38+
private readonly MinimalPermissionsGuidancePluginConfiguration _configuration = new();
39+
private Dictionary<string, OpenApiDocument>? _apiSpecsByUrl;
40+
public override string Name => nameof(MinimalPermissionsGuidancePlugin);
41+
private IProxyConfiguration? _proxyConfiguration;
42+
43+
public override async Task RegisterAsync()
44+
{
45+
await base.RegisterAsync();
46+
47+
ConfigSection?.Bind(_configuration);
48+
_proxyConfiguration = Context.Configuration;
49+
50+
if (string.IsNullOrWhiteSpace(_configuration.ApiSpecsFolderPath))
51+
{
52+
throw new InvalidOperationException("ApiSpecsFolderPath is required.");
53+
}
54+
_configuration.ApiSpecsFolderPath = Path.GetFullPath(
55+
ProxyUtils.ReplacePathTokens(_configuration.ApiSpecsFolderPath),
56+
Path.GetDirectoryName(_proxyConfiguration?.ConfigFile ?? string.Empty) ?? string.Empty);
57+
if (!Path.Exists(_configuration.ApiSpecsFolderPath))
58+
{
59+
throw new InvalidOperationException($"ApiSpecsFolderPath '{_configuration.ApiSpecsFolderPath}' does not exist.");
60+
}
61+
62+
PluginEvents.AfterRecordingStop += AfterRecordingStopAsync;
63+
}
64+
65+
#pragma warning disable CS1998
66+
private async Task AfterRecordingStopAsync(object sender, RecordingArgs e)
67+
#pragma warning restore CS1998
68+
{
69+
var interceptedRequests = e.RequestLogs
70+
.Where(l =>
71+
l.MessageType == MessageType.InterceptedRequest &&
72+
!l.Message.StartsWith("OPTIONS") &&
73+
l.Context?.Session is not null &&
74+
l.Context.Session.HttpClient.Request.Headers.Any(h => h.Name.Equals("authorization", StringComparison.OrdinalIgnoreCase))
75+
);
76+
if (!interceptedRequests.Any())
77+
{
78+
Logger.LogDebug("No requests to process");
79+
return;
80+
}
81+
82+
Logger.LogInformation("Checking if recorded API requests use minimal permissions as defined in API specs...");
83+
84+
_apiSpecsByUrl ??= LoadApiSpecs(_configuration.ApiSpecsFolderPath!);
85+
if (_apiSpecsByUrl is null || _apiSpecsByUrl.Count == 0)
86+
{
87+
Logger.LogWarning("No API definitions found in the specified folder.");
88+
return;
89+
}
90+
91+
var (requestsByApiSpec, unmatchedApiSpecRequests) = GetRequestsByApiSpec(interceptedRequests, _apiSpecsByUrl);
92+
93+
var errors = new List<ApiPermissionError>();
94+
var results = new List<MinimalPermissionsGuidancePluginReportApiResult>();
95+
var unmatchedRequests = new List<string>(
96+
unmatchedApiSpecRequests.Select(r => r.Message)
97+
);
98+
99+
foreach (var (apiSpec, requests) in requestsByApiSpec)
100+
{
101+
var minimalPermissions = apiSpec.CheckMinimalPermissions(requests, Logger);
102+
103+
var result = new MinimalPermissionsGuidancePluginReportApiResult
104+
{
105+
ApiName = GetApiName(minimalPermissions.OperationsFromRequests.Count > 0 ?
106+
minimalPermissions.OperationsFromRequests.First().OriginalUrl : null),
107+
Requests = minimalPermissions.OperationsFromRequests
108+
.Select(o => $"{o.Method} {o.OriginalUrl}")
109+
.Distinct()
110+
.ToArray(),
111+
TokenPermissions = minimalPermissions.TokenPermissions.Distinct().ToArray(),
112+
MinimalPermissions = minimalPermissions.MinimalScopes,
113+
ExcessivePermissions = minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).ToArray(),
114+
UsesMinimalPermissions = !minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).Any()
115+
};
116+
results.Add(result);
117+
118+
var unmatchedApiRequests = minimalPermissions.OperationsFromRequests
119+
.Where(o => minimalPermissions.UnmatchedOperations.Contains($"{o.Method} {o.TokenizedUrl}"))
120+
.Select(o => $"{o.Method} {o.OriginalUrl}");
121+
unmatchedRequests.AddRange(unmatchedApiRequests);
122+
errors.AddRange(minimalPermissions.Errors);
123+
124+
if (result.UsesMinimalPermissions)
125+
{
126+
Logger.LogInformation(
127+
"API {apiName} is called with minimal permissions: {minimalPermissions}",
128+
result.ApiName,
129+
string.Join(", ", result.MinimalPermissions)
130+
);
131+
}
132+
else
133+
{
134+
Logger.LogWarning(
135+
"Calling API {apiName} with excessive permissions: {excessivePermissions}. Minimal permissions are: {minimalPermissions}",
136+
result.ApiName,
137+
string.Join(", ", result.ExcessivePermissions),
138+
string.Join(", ", result.MinimalPermissions)
139+
);
140+
}
141+
142+
if (unmatchedApiRequests.Any())
143+
{
144+
Logger.LogWarning(
145+
"Unmatched requests for API {apiName}:{newLine}- {unmatchedRequests}",
146+
result.ApiName,
147+
Environment.NewLine,
148+
string.Join($"{Environment.NewLine}- ", unmatchedApiRequests)
149+
);
150+
}
151+
152+
if (minimalPermissions.Errors.Count != 0)
153+
{
154+
Logger.LogWarning(
155+
"Errors for API {apiName}:{newLine}- {errors}",
156+
result.ApiName,
157+
Environment.NewLine,
158+
string.Join($"{Environment.NewLine}- ", minimalPermissions.Errors.Select(e => $"{e.Request}: {e.Error}"))
159+
);
160+
}
161+
}
162+
163+
var report = new MinimalPermissionsGuidancePluginReport()
164+
{
165+
Results = [.. results],
166+
UnmatchedRequests = [.. unmatchedRequests],
167+
Errors = [.. errors]
168+
};
169+
170+
StoreReport(report, e);
171+
}
172+
173+
private Dictionary<string, OpenApiDocument> LoadApiSpecs(string apiSpecsFolderPath)
174+
{
175+
var apiDefinitions = new Dictionary<string, OpenApiDocument>();
176+
foreach (var file in Directory.EnumerateFiles(apiSpecsFolderPath, "*.*", SearchOption.AllDirectories))
177+
{
178+
var extension = Path.GetExtension(file);
179+
if (!extension.Equals(".json", StringComparison.OrdinalIgnoreCase) &&
180+
!extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) &&
181+
!extension.Equals(".yml", StringComparison.OrdinalIgnoreCase))
182+
{
183+
Logger.LogDebug("Skipping file '{file}' because it is not a JSON or YAML file", file);
184+
continue;
185+
}
186+
187+
Logger.LogDebug("Processing file '{file}'...", file);
188+
try
189+
{
190+
var apiDefinition = new OpenApiStringReader().Read(File.ReadAllText(file), out _);
191+
if (apiDefinition is null)
192+
{
193+
continue;
194+
}
195+
if (apiDefinition.Servers is null || apiDefinition.Servers.Count == 0)
196+
{
197+
Logger.LogDebug("No servers found in API definition file '{file}'", file);
198+
continue;
199+
}
200+
foreach (var server in apiDefinition.Servers)
201+
{
202+
if (server.Url is null)
203+
{
204+
Logger.LogDebug("No URL found for server '{server}'", server.Description ?? "unnamed");
205+
continue;
206+
}
207+
apiDefinitions[server.Url] = apiDefinition;
208+
}
209+
}
210+
catch (Exception ex)
211+
{
212+
Logger.LogError(ex, "Failed to load API definition from file '{file}'", file);
213+
}
214+
}
215+
return apiDefinitions;
216+
}
217+
218+
private (Dictionary<OpenApiDocument, List<RequestLog>> RequestsByApiSpec, IEnumerable<RequestLog> UnmatchedRequests) GetRequestsByApiSpec(IEnumerable<RequestLog> interceptedRequests, Dictionary<string, OpenApiDocument> apiSpecsByUrl)
219+
{
220+
var unmatchedRequests = new List<RequestLog>();
221+
var requestsByApiSpec = new Dictionary<OpenApiDocument, List<RequestLog>>();
222+
foreach (var request in interceptedRequests)
223+
{
224+
var url = request.Message.Split(' ')[1];
225+
Logger.LogDebug("Matching request {requestUrl} to API specs...", url);
226+
227+
var matchingKey = apiSpecsByUrl.Keys.FirstOrDefault(url.StartsWith);
228+
if (matchingKey is null)
229+
{
230+
Logger.LogDebug("No matching API spec found for {requestUrl}", url);
231+
unmatchedRequests.Add(request);
232+
continue;
233+
}
234+
235+
if (!requestsByApiSpec.TryGetValue(apiSpecsByUrl[matchingKey], out List<RequestLog>? value))
236+
{
237+
value = [];
238+
requestsByApiSpec[apiSpecsByUrl[matchingKey]] = value;
239+
}
240+
241+
value.Add(request);
242+
}
243+
244+
return (requestsByApiSpec, unmatchedRequests);
245+
}
246+
247+
private static string GetApiName(string? url)
248+
{
249+
if (string.IsNullOrWhiteSpace(url))
250+
{
251+
return "Unknown";
252+
}
253+
254+
var uri = new Uri(url);
255+
return uri.Authority;
256+
}
257+
}

dev-proxy-plugins/RequestLogs/MinimalPermissionsPlugin.cs

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ public class MinimalPermissionsPluginReportApiResult
1717
public required string[] Requests { get; init; }
1818
public required string[] TokenPermissions { get; init; }
1919
public required string[] MinimalPermissions { get; init; }
20-
public required string[] ExcessivePermissions { get; init; }
21-
public required bool UsesMinimalPermissions { get; init; }
2220
}
2321

2422
public class MinimalPermissionsPluginReport
@@ -109,9 +107,7 @@ private async Task AfterRecordingStopAsync(object sender, RecordingArgs e)
109107
.Distinct()
110108
.ToArray(),
111109
TokenPermissions = minimalPermissions.TokenPermissions.Distinct().ToArray(),
112-
MinimalPermissions = minimalPermissions.MinimalScopes,
113-
ExcessivePermissions = minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).ToArray(),
114-
UsesMinimalPermissions = !minimalPermissions.TokenPermissions.Except(minimalPermissions.MinimalScopes).Any()
110+
MinimalPermissions = minimalPermissions.MinimalScopes
115111
};
116112
results.Add(result);
117113

@@ -121,24 +117,6 @@ private async Task AfterRecordingStopAsync(object sender, RecordingArgs e)
121117
unmatchedRequests.AddRange(unmatchedApiRequests);
122118
errors.AddRange(minimalPermissions.Errors);
123119

124-
if (result.UsesMinimalPermissions)
125-
{
126-
Logger.LogInformation(
127-
"API {apiName} is called with minimal permissions: {minimalPermissions}",
128-
result.ApiName,
129-
string.Join(", ", result.MinimalPermissions)
130-
);
131-
}
132-
else
133-
{
134-
Logger.LogWarning(
135-
"Calling API {apiName} with excessive permissions: {excessivePermissions}. Minimal permissions are: {minimalPermissions}",
136-
result.ApiName,
137-
string.Join(", ", result.ExcessivePermissions),
138-
string.Join(", ", result.MinimalPermissions)
139-
);
140-
}
141-
142120
if (unmatchedApiRequests.Any())
143121
{
144122
Logger.LogWarning(

0 commit comments

Comments
 (0)