Skip to content

Commit 2baeee9

Browse files
Adds the minimal permission guidance plugin. Closes #213 (#276)
1 parent 6b2d6b7 commit 2baeee9

File tree

11 files changed

+353
-81
lines changed

11 files changed

+353
-81
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace Microsoft365.DeveloperProxy.Plugins.RequestLogs;
4+
5+
internal class MethodAndUrlComparer : IEqualityComparer<Tuple<string, string>>
6+
{
7+
public bool Equals(Tuple<string, string>? x, Tuple<string, string>? y)
8+
{
9+
if (object.ReferenceEquals(x, y))
10+
{
11+
return true;
12+
}
13+
14+
if (object.ReferenceEquals(x, null) || object.ReferenceEquals(y, null))
15+
{
16+
return false;
17+
}
18+
19+
return x.Item1 == y.Item1 && x.Item2 == y.Item2;
20+
}
21+
22+
public int GetHashCode([DisallowNull] Tuple<string, string> obj)
23+
{
24+
if (obj == null)
25+
{
26+
return 0;
27+
}
28+
29+
int methodHashCode = obj.Item1.GetHashCode();
30+
int urlHashCode = obj.Item2.GetHashCode();
31+
32+
return methodHashCode ^ urlHashCode;
33+
}
34+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Microsoft365.DeveloperProxy.Plugins.RequestLogs.MinimalPermissions;
4+
5+
internal class PermissionError
6+
{
7+
[JsonPropertyName("requestUrl")]
8+
public string Url { get; set; } = string.Empty;
9+
[JsonPropertyName("message")]
10+
public string Message { get; set; } = string.Empty;
11+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Microsoft365.DeveloperProxy.Plugins.RequestLogs.MinimalPermissions;
4+
5+
internal class PermissionInfo
6+
{
7+
[JsonPropertyName("value")]
8+
public string Value { get; set; } = string.Empty;
9+
[JsonPropertyName("scopeType")]
10+
public string ScopeType { get; set; } = string.Empty;
11+
[JsonPropertyName("consentDisplayName")]
12+
public string ConsentDisplayName { get; set; } = string.Empty;
13+
[JsonPropertyName("consentDescription")]
14+
public string ConsentDescription { get; set; } = string.Empty;
15+
[JsonPropertyName("isAdmin")]
16+
public bool IsAdmin { get; set; }
17+
[JsonPropertyName("isLeastPrivilege")]
18+
public bool IsLeastPrivilege { get; set; }
19+
[JsonPropertyName("isHidden")]
20+
public bool IsHidden { get; set; }
21+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Microsoft365.DeveloperProxy.Plugins.RequestLogs.MinimalPermissions;
2+
3+
internal enum PermissionsType
4+
{
5+
Application,
6+
Delegated
7+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
2+
using System.Text.Json.Serialization;
3+
4+
namespace Microsoft365.DeveloperProxy.Plugins.RequestLogs.MinimalPermissions;
5+
6+
internal class RequestInfo
7+
{
8+
[JsonPropertyName("requestUrl")]
9+
public string Url { get; set; } = string.Empty;
10+
[JsonPropertyName("method")]
11+
public string Method { get; set; } = string.Empty;
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Microsoft365.DeveloperProxy.Plugins.RequestLogs.MinimalPermissions;
4+
5+
internal class ResultsAndErrors
6+
{
7+
[JsonPropertyName("results")]
8+
public PermissionInfo[]? Results { get; set; }
9+
[JsonPropertyName("errors")]
10+
public PermissionError[]? Errors { get; set; }
11+
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft365.DeveloperProxy.Abstractions;
6+
using Microsoft365.DeveloperProxy.Plugins.RequestLogs.MinimalPermissions;
7+
using System.IdentityModel.Tokens.Jwt;
8+
using System.Net.Http.Json;
9+
using System.Text.Json;
10+
11+
namespace Microsoft365.DeveloperProxy.Plugins.RequestLogs;
12+
13+
public class MinimalPermissionsGuidancePlugin : BaseProxyPlugin
14+
{
15+
public override string Name => nameof(MinimalPermissionsGuidancePlugin);
16+
17+
public override void Register(IPluginEvents pluginEvents,
18+
IProxyContext context,
19+
ISet<UrlToWatch> urlsToWatch,
20+
IConfigurationSection? configSection = null)
21+
{
22+
base.Register(pluginEvents, context, urlsToWatch, configSection);
23+
24+
pluginEvents.AfterRecordingStop += AfterRecordingStop;
25+
}
26+
private async void AfterRecordingStop(object? sender, RecordingArgs e)
27+
{
28+
if (!e.RequestLogs.Any())
29+
{
30+
return;
31+
}
32+
33+
var methodAndUrlComparer = new MethodAndUrlComparer();
34+
var delegatedEndpoints = new List<Tuple<string, string>>();
35+
var applicationEndpoints = new List<Tuple<string, string>>();
36+
37+
// scope for delegated permissions
38+
var scopesToEvaluate = Array.Empty<string>();
39+
// roles for application permissions
40+
var rolesToEvaluate = Array.Empty<string>();
41+
42+
foreach (var request in e.RequestLogs)
43+
{
44+
if (request.MessageType != MessageType.InterceptedRequest)
45+
{
46+
continue;
47+
}
48+
49+
var methodAndUrlString = request.Message.First();
50+
var methodAndUrl = GetMethodAndUrl(methodAndUrlString);
51+
52+
if (!ProxyUtils.IsGraphUrl(new Uri(methodAndUrl.Item2)))
53+
{
54+
continue;
55+
}
56+
57+
methodAndUrl = new Tuple<string, string>(methodAndUrl.Item1, GetTokenizedUrl(methodAndUrl.Item2));
58+
59+
var scopesAndType = GetPermissionsAndType(request);
60+
if (scopesAndType.Item1 == PermissionsType.Delegated)
61+
{
62+
// use the scopes from the last request in case the app is using incremental consent
63+
scopesToEvaluate = scopesAndType.Item2;
64+
65+
delegatedEndpoints.Add(methodAndUrl);
66+
}
67+
else
68+
{
69+
// skip empty roles which are returned in case we couldn't get permissions information
70+
//
71+
// application permissions are always the same because they come from app reg
72+
// so we can just use the first request that has them
73+
if (scopesAndType.Item2.Length > 0 &&
74+
rolesToEvaluate.Length == 0) {
75+
rolesToEvaluate = scopesAndType.Item2;
76+
77+
applicationEndpoints.Add(methodAndUrl);
78+
}
79+
}
80+
}
81+
82+
// Remove duplicates
83+
delegatedEndpoints = delegatedEndpoints.Distinct(methodAndUrlComparer).ToList();
84+
applicationEndpoints = applicationEndpoints.Distinct(methodAndUrlComparer).ToList();
85+
86+
if (delegatedEndpoints.Count == 0 && applicationEndpoints.Count == 0)
87+
{
88+
return;
89+
}
90+
91+
_logger?.LogWarn("This plugin is in preview and may not return the correct results.");
92+
_logger?.LogWarn("Please review the permissions and test your app before using them in production.");
93+
_logger?.LogWarn("If you have any feedback, please open an issue at https://aka.ms/m365/proxy/issue.");
94+
_logger?.LogInfo("");
95+
96+
if (delegatedEndpoints.Count > 0) {
97+
_logger?.LogInfo("Evaluating delegated permissions for:");
98+
_logger?.LogInfo("");
99+
_logger?.LogInfo(string.Join(Environment.NewLine, delegatedEndpoints.Select(e => $"- {e.Item1} {e.Item2}")));
100+
_logger?.LogInfo("");
101+
102+
await EvaluateMinimalScopes(delegatedEndpoints, scopesToEvaluate, PermissionsType.Delegated);
103+
}
104+
105+
if (applicationEndpoints.Count > 0) {
106+
_logger?.LogInfo("Evaluating application permissions for:");
107+
_logger?.LogInfo("");
108+
_logger?.LogInfo(string.Join(Environment.NewLine, applicationEndpoints.Select(e => $"- {e.Item1} {e.Item2}")));
109+
_logger?.LogInfo("");
110+
111+
await EvaluateMinimalScopes(applicationEndpoints, rolesToEvaluate, PermissionsType.Application);
112+
}
113+
}
114+
115+
/// <summary>
116+
/// Returns permissions and type (delegated or application) from the access token
117+
/// used on the request.
118+
/// If it can't get the permissions, returns PermissionType.Application for Item1
119+
/// and an empty array for Item2.
120+
/// </summary>
121+
private Tuple<PermissionsType, string[]> GetPermissionsAndType(RequestLog request) {
122+
var authHeader = request.Context?.Session.HttpClient.Request.Headers.GetFirstHeader("Authorization");
123+
if (authHeader == null)
124+
{
125+
return new Tuple<PermissionsType, string[]>(PermissionsType.Application, Array.Empty<string>());
126+
}
127+
128+
var token = authHeader.Value.Replace("Bearer ", string.Empty);
129+
var tokenChunks = token.Split('.');
130+
if (tokenChunks.Length != 3)
131+
{
132+
return new Tuple<PermissionsType, string[]>(PermissionsType.Application, Array.Empty<string>());
133+
}
134+
135+
try {
136+
var handler = new JwtSecurityTokenHandler();
137+
var jwtSecurityToken = handler.ReadJwtToken(token);
138+
139+
var scopeClaim = jwtSecurityToken.Claims.FirstOrDefault(c => c.Type == "scp");
140+
if (scopeClaim == null)
141+
{
142+
// possibly an application token
143+
// roles is an array so we need to handle it differently
144+
var roles = jwtSecurityToken.Claims
145+
.Where(c => c.Type == "roles")
146+
.Select(c => c.Value)
147+
.ToArray();
148+
if (roles.Length == 0)
149+
{
150+
return new Tuple<PermissionsType, string[]>(PermissionsType.Application, Array.Empty<string>());
151+
}
152+
else
153+
{
154+
return new Tuple<PermissionsType, string[]>(PermissionsType.Application, roles);
155+
}
156+
}
157+
else {
158+
return new Tuple<PermissionsType, string[]>(PermissionsType.Delegated, scopeClaim.Value.Split(' '));
159+
}
160+
}
161+
catch {
162+
return new Tuple<PermissionsType, string[]>(PermissionsType.Application, Array.Empty<string>());
163+
}
164+
}
165+
166+
private string GetScopeTypeString(PermissionsType scopeType)
167+
{
168+
return scopeType switch
169+
{
170+
PermissionsType.Application => "Application",
171+
PermissionsType.Delegated => "DelegatedWork",
172+
_ => throw new InvalidOperationException($"Unknown scope type: {scopeType}")
173+
};
174+
}
175+
176+
private async Task EvaluateMinimalScopes(IEnumerable<Tuple<string, string>> endpoints, string[] permissionsFromAccessToken, PermissionsType scopeType)
177+
{
178+
var payload = endpoints.Select(e => new RequestInfo { Method = e.Item1, Url = e.Item2 });
179+
180+
try
181+
{
182+
var url = $"https://graphexplorerapi-staging.azurewebsites.net/permissions?scopeType={GetScopeTypeString(scopeType)}";
183+
using (var client = new HttpClient())
184+
{
185+
var stringPayload = JsonSerializer.Serialize(payload);
186+
_logger?.LogDebug($"Calling {url} with payload{Environment.NewLine}{stringPayload}");
187+
188+
var response = await client.PostAsJsonAsync(url, payload);
189+
var content = await response.Content.ReadAsStringAsync();
190+
191+
_logger?.LogDebug($"Response:{Environment.NewLine}{content}");
192+
193+
var resultsAndErrors = JsonSerializer.Deserialize<ResultsAndErrors>(content);
194+
var minimalScopes = resultsAndErrors?.Results?.Select(p => p.Value).ToArray() ?? Array.Empty<string>();
195+
var errors = resultsAndErrors?.Errors?.Select(e => $"- {e.Url} ({e.Message})") ?? Array.Empty<string>();
196+
if (minimalScopes.Any())
197+
{
198+
var excessPermissions = permissionsFromAccessToken
199+
.Where(p => !minimalScopes.Contains(p))
200+
.ToArray();
201+
202+
_logger?.LogInfo("Minimal permissions:");
203+
_logger?.LogInfo(string.Join(", ", minimalScopes));
204+
_logger?.LogInfo("");
205+
_logger?.LogInfo("Permissions on the token:");
206+
_logger?.LogInfo(string.Join(", ", permissionsFromAccessToken));
207+
_logger?.LogInfo("");
208+
209+
if (excessPermissions.Any())
210+
{
211+
_logger?.LogWarn("The following permissions are unnecessary:");
212+
_logger?.LogWarn(string.Join(", ", excessPermissions));
213+
_logger?.LogInfo("");
214+
}
215+
else
216+
{
217+
_logger?.LogInfo("The token has the minimal permissions required.");
218+
_logger?.LogInfo("");
219+
}
220+
}
221+
if (errors.Any())
222+
{
223+
_logger?.LogError("Couldn't determine minimal permissions for the following URLs:");
224+
_logger?.LogError(string.Join(Environment.NewLine, errors));
225+
}
226+
}
227+
}
228+
catch (Exception ex)
229+
{
230+
_logger?.LogError($"An error has occurred while retrieving minimal permissions: {ex.Message}");
231+
}
232+
}
233+
234+
private Tuple<string, string> GetMethodAndUrl(string message)
235+
{
236+
var info = message.Split(" ");
237+
if (info.Length > 2)
238+
{
239+
info = new[] { info[0], String.Join(" ", info.Skip(1)) };
240+
}
241+
return new Tuple<string, string>(info[0], info[1]);
242+
}
243+
244+
private string GetTokenizedUrl(string absoluteUrl)
245+
{
246+
var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl);
247+
return "/" + String.Join("", new Uri(sanitizedUrl).Segments.Skip(2).Select(s => Uri.UnescapeDataString(s)));
248+
}
249+
}

0 commit comments

Comments
 (0)