From 98ad667839494bd0c7e8bba9b6581ac9b590ff8b Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Tue, 7 Nov 2023 13:58:19 +0100 Subject: [PATCH] Adds storing minimal permissions in a file. Closes #366 (#377) --- .../MinimalPermissionsGuidancePlugin.cs | 232 ++++++++++++++---- 1 file changed, 183 insertions(+), 49 deletions(-) diff --git a/m365-developer-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs b/m365-developer-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs index a7c69101..36dc1918 100644 --- a/m365-developer-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs +++ b/m365-developer-proxy-plugins/RequestLogs/MinimalPermissionsGuidancePlugin.cs @@ -4,15 +4,74 @@ using Microsoft.Extensions.Configuration; using Microsoft365.DeveloperProxy.Abstractions; using Microsoft365.DeveloperProxy.Plugins.RequestLogs.MinimalPermissions; +using System.CommandLine; +using System.CommandLine.Invocation; using System.IdentityModel.Tokens.Jwt; using System.Net.Http.Json; using System.Text.Json; +using System.Text.Json.Serialization; namespace Microsoft365.DeveloperProxy.Plugins.RequestLogs; +internal class MinimalPermissionsGuidancePluginConfiguration +{ + public string FilePath { get; set; } = ""; +} + +internal class OperationInfo +{ + [JsonPropertyName("method")] + public string Method { get; set; } = string.Empty; + [JsonPropertyName("endpoint")] + public string Endpoint { get; set; } = string.Empty; +} + +internal class MinimalPermissionsInfo +{ + [JsonPropertyName("minimalPermissions")] + public string[] MinimalPermissions { get; set; } = Array.Empty(); + [JsonPropertyName("permissionsFromTheToken")] + public string[] PermissionsFromTheToken { get; set; } = Array.Empty(); + [JsonPropertyName("excessPermissions")] + public string[] ExcessPermissions { get; set; } = Array.Empty(); + [JsonPropertyName("operations")] + public OperationInfo[] Operations { get; set; } = Array.Empty(); +} + public class MinimalPermissionsGuidancePlugin : BaseProxyPlugin { public override string Name => nameof(MinimalPermissionsGuidancePlugin); + private MinimalPermissionsGuidancePluginConfiguration _configuration = new(); + private readonly Option _filePath; + + public MinimalPermissionsGuidancePlugin() + { + _filePath = new Option("--minimal-permissions-summary-file-path", "Path to the file where the permissions summary should be saved. If not specified, the summary will be printed to the console. Path can be absolute or relative to the current working directory.") + { + ArgumentHelpName = "minimal-permissions-summary-file-path" + }; + _filePath.AddValidator(input => + { + var outputFilePath = input.Tokens.First().Value; + if (string.IsNullOrEmpty(outputFilePath)) + { + return; + } + + var dirName = Path.GetDirectoryName(outputFilePath); + if (string.IsNullOrEmpty(dirName)) + { + // current directory exists so no need to check + return; + } + + var outputDir = Path.GetFullPath(dirName); + if (!Directory.Exists(outputDir)) + { + input.ErrorMessage = $"The directory {outputDir} does not exist."; + } + }); + } public override void Register(IPluginEvents pluginEvents, IProxyContext context, @@ -21,8 +80,29 @@ public override void Register(IPluginEvents pluginEvents, { base.Register(pluginEvents, context, urlsToWatch, configSection); + configSection?.Bind(_configuration); + + pluginEvents.Init += Init; + pluginEvents.OptionsLoaded += OptionsLoaded; pluginEvents.AfterRecordingStop += AfterRecordingStop; } + + private void OptionsLoaded(object? sender, OptionsLoadedArgs e) + { + InvocationContext context = e.Context; + + var filePath = context.ParseResult.GetValueForOption(_filePath); + if (filePath is not null) + { + _configuration.FilePath = filePath; + } + } + + private void Init(object? sender, InitArgs e) + { + e.RootCommand.AddOption(_filePath); + } + private async Task AfterRecordingStop(object? sender, RecordingArgs e) { if (!e.RequestLogs.Any()) @@ -56,11 +136,13 @@ private async Task AfterRecordingStop(object? sender, RecordingArgs e) continue; } - if (ProxyUtils.IsGraphBatchUrl(uri)) { + if (ProxyUtils.IsGraphBatchUrl(uri)) + { var graphVersion = ProxyUtils.IsGraphBetaUrl(uri) ? "beta" : "v1.0"; requestsFromBatch = GetRequestsFromBatch(request.Context?.Session.HttpClient.Request.BodyString!, graphVersion, uri.Host); } - else { + else + { methodAndUrl = new Tuple(methodAndUrl.Item1, GetTokenizedUrl(methodAndUrl.Item2)); } @@ -70,10 +152,12 @@ private async Task AfterRecordingStop(object? sender, RecordingArgs e) // use the scopes from the last request in case the app is using incremental consent scopesToEvaluate = scopesAndType.Item2; - if (ProxyUtils.IsGraphBatchUrl(uri)) { + if (ProxyUtils.IsGraphBatchUrl(uri)) + { delegatedEndpoints.AddRange(requestsFromBatch); } - else { + else + { delegatedEndpoints.Add(methodAndUrl); } } @@ -84,13 +168,16 @@ private async Task AfterRecordingStop(object? sender, RecordingArgs e) // application permissions are always the same because they come from app reg // so we can just use the first request that has them if (scopesAndType.Item2.Length > 0 && - rolesToEvaluate.Length == 0) { + rolesToEvaluate.Length == 0) + { rolesToEvaluate = scopesAndType.Item2; - if (ProxyUtils.IsGraphBatchUrl(uri)) { + if (ProxyUtils.IsGraphBatchUrl(uri)) + { applicationEndpoints.AddRange(requestsFromBatch); } - else { + else + { applicationEndpoints.Add(methodAndUrl); } } @@ -106,27 +193,55 @@ private async Task AfterRecordingStop(object? sender, RecordingArgs e) return; } - _logger?.LogWarn("This plugin is in preview and may not return the correct results."); - _logger?.LogWarn("Please review the permissions and test your app before using them in production."); - _logger?.LogWarn("If you have any feedback, please open an issue at https://aka.ms/m365/proxy/issue."); - _logger?.LogInfo(""); + var minimalPermissionsInfo = new List(); - if (delegatedEndpoints.Count > 0) { - _logger?.LogInfo("Evaluating delegated permissions for:"); - _logger?.LogInfo(""); - _logger?.LogInfo(string.Join(Environment.NewLine, delegatedEndpoints.Select(e => $"- {e.Item1} {e.Item2}"))); + if (string.IsNullOrEmpty(_configuration.FilePath)) + { + _logger?.LogWarn("This plugin is in preview and may not return the correct results."); + _logger?.LogWarn("Please review the permissions and test your app before using them in production."); + _logger?.LogWarn("If you have any feedback, please open an issue at https://aka.ms/m365/proxy/issue."); _logger?.LogInfo(""); + } + + if (delegatedEndpoints.Count > 0) + { + var delegatedPermissionsInfo = new MinimalPermissionsInfo(); + minimalPermissionsInfo.Add(delegatedPermissionsInfo); + + if (string.IsNullOrEmpty(_configuration.FilePath)) + { + _logger?.LogInfo("Evaluating delegated permissions for:"); + _logger?.LogInfo(""); + _logger?.LogInfo(string.Join(Environment.NewLine, delegatedEndpoints.Select(e => $"- {e.Item1} {e.Item2}"))); + _logger?.LogInfo(""); + } - await EvaluateMinimalScopes(delegatedEndpoints, scopesToEvaluate, PermissionsType.Delegated); + await EvaluateMinimalScopes(delegatedEndpoints, scopesToEvaluate, PermissionsType.Delegated, delegatedPermissionsInfo); } - if (applicationEndpoints.Count > 0) { - _logger?.LogInfo("Evaluating application permissions for:"); - _logger?.LogInfo(""); - _logger?.LogInfo(string.Join(Environment.NewLine, applicationEndpoints.Select(e => $"- {e.Item1} {e.Item2}"))); - _logger?.LogInfo(""); - - await EvaluateMinimalScopes(applicationEndpoints, rolesToEvaluate, PermissionsType.Application); + if (applicationEndpoints.Count > 0) + { + var applicationPermissionsInfo = new MinimalPermissionsInfo(); + minimalPermissionsInfo.Add(applicationPermissionsInfo); + + if (string.IsNullOrEmpty(_configuration.FilePath)) + { + _logger?.LogInfo("Evaluating application permissions for:"); + _logger?.LogInfo(""); + _logger?.LogInfo(string.Join(Environment.NewLine, applicationEndpoints.Select(e => $"- {e.Item1} {e.Item2}"))); + _logger?.LogInfo(""); + } + + await EvaluateMinimalScopes(applicationEndpoints, rolesToEvaluate, PermissionsType.Application, applicationPermissionsInfo); + } + + if (!string.IsNullOrEmpty(_configuration.FilePath)) + { + var json = JsonSerializer.Serialize(minimalPermissionsInfo, new JsonSerializerOptions + { + WriteIndented = true + }); + await File.WriteAllTextAsync(_configuration.FilePath, json); } } @@ -139,7 +254,8 @@ private Tuple[] GetRequestsFromBatch(string batchBody, string gr return requests.ToArray(); } - try { + try + { var batch = JsonSerializer.Deserialize(batchBody); if (batch == null) { @@ -148,16 +264,17 @@ private Tuple[] GetRequestsFromBatch(string batchBody, string gr foreach (var request in batch.Requests) { - try { + try + { var method = request.Method; var url = request.Url; var absoluteUrl = $"https://{graphHostName}/{graphVersion}{url}"; requests.Add(new Tuple(method, GetTokenizedUrl(absoluteUrl))); } - catch {} + catch { } } } - catch {} + catch { } return requests.ToArray(); } @@ -168,7 +285,8 @@ private Tuple[] GetRequestsFromBatch(string batchBody, string gr /// If it can't get the permissions, returns PermissionType.Application for Item1 /// and an empty array for Item2. /// - private Tuple GetPermissionsAndType(RequestLog request) { + private Tuple GetPermissionsAndType(RequestLog request) + { var authHeader = request.Context?.Session.HttpClient.Request.Headers.GetFirstHeader("Authorization"); if (authHeader == null) { @@ -182,7 +300,8 @@ private Tuple GetPermissionsAndType(RequestLog reques return new Tuple(PermissionsType.Application, Array.Empty()); } - try { + try + { var handler = new JwtSecurityTokenHandler(); var jwtSecurityToken = handler.ReadJwtToken(token); @@ -204,11 +323,13 @@ private Tuple GetPermissionsAndType(RequestLog reques return new Tuple(PermissionsType.Application, roles); } } - else { + else + { return new Tuple(PermissionsType.Delegated, scopeClaim.Value.Split(' ')); } } - catch { + catch + { return new Tuple(PermissionsType.Application, Array.Empty()); } } @@ -223,10 +344,17 @@ private string GetScopeTypeString(PermissionsType scopeType) }; } - private async Task EvaluateMinimalScopes(IEnumerable> endpoints, string[] permissionsFromAccessToken, PermissionsType scopeType) + private async Task EvaluateMinimalScopes(IEnumerable> endpoints, string[] permissionsFromAccessToken, PermissionsType scopeType, MinimalPermissionsInfo permissionsInfo) { var payload = endpoints.Select(e => new RequestInfo { Method = e.Item1, Url = e.Item2 }); + permissionsInfo.Operations = endpoints.Select(e => new OperationInfo + { + Method = e.Item1, + Endpoint = e.Item2 + }).ToArray(); + permissionsInfo.PermissionsFromTheToken = permissionsFromAccessToken; + try { var url = $"https://graphexplorerapi-staging.azurewebsites.net/permissions?scopeType={GetScopeTypeString(scopeType)}"; @@ -241,31 +369,37 @@ private async Task EvaluateMinimalScopes(IEnumerable> endp _logger?.LogDebug($"Response:{Environment.NewLine}{content}"); var resultsAndErrors = JsonSerializer.Deserialize(content); - var minimalScopes = resultsAndErrors?.Results?.Select(p => p.Value).ToArray() ?? Array.Empty(); + var minimalPermissions = resultsAndErrors?.Results?.Select(p => p.Value).ToArray() ?? Array.Empty(); var errors = resultsAndErrors?.Errors?.Select(e => $"- {e.Url} ({e.Message})") ?? Array.Empty(); - if (minimalScopes.Any()) + if (minimalPermissions.Any()) { var excessPermissions = permissionsFromAccessToken - .Where(p => !minimalScopes.Contains(p)) + .Where(p => !minimalPermissions.Contains(p)) .ToArray(); + + permissionsInfo.MinimalPermissions = minimalPermissions; + permissionsInfo.ExcessPermissions = excessPermissions; - _logger?.LogInfo("Minimal permissions:"); - _logger?.LogInfo(string.Join(", ", minimalScopes)); - _logger?.LogInfo(""); - _logger?.LogInfo("Permissions on the token:"); - _logger?.LogInfo(string.Join(", ", permissionsFromAccessToken)); - _logger?.LogInfo(""); - - if (excessPermissions.Any()) + if (string.IsNullOrEmpty(_configuration.FilePath)) { - _logger?.LogWarn("The following permissions are unnecessary:"); - _logger?.LogWarn(string.Join(", ", excessPermissions)); + _logger?.LogInfo("Minimal permissions:"); + _logger?.LogInfo(string.Join(", ", minimalPermissions)); _logger?.LogInfo(""); - } - else - { - _logger?.LogInfo("The token has the minimal permissions required."); + _logger?.LogInfo("Permissions on the token:"); + _logger?.LogInfo(string.Join(", ", permissionsFromAccessToken)); _logger?.LogInfo(""); + + if (excessPermissions.Any()) + { + _logger?.LogWarn("The following permissions are unnecessary:"); + _logger?.LogWarn(string.Join(", ", excessPermissions)); + _logger?.LogInfo(""); + } + else + { + _logger?.LogInfo("The token has the minimal permissions required."); + _logger?.LogInfo(""); + } } } if (errors.Any())