|
| 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