Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds storing minimal permissions in a file. Closes #366 #377

Merged
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
[JsonPropertyName("permissionsFromTheToken")]
public string[] PermissionsFromTheToken { get; set; } = Array.Empty<string>();
[JsonPropertyName("excessPermissions")]
public string[] ExcessPermissions { get; set; } = Array.Empty<string>();
[JsonPropertyName("operations")]
public OperationInfo[] Operations { get; set; } = Array.Empty<OperationInfo>();
}

public class MinimalPermissionsGuidancePlugin : BaseProxyPlugin
{
public override string Name => nameof(MinimalPermissionsGuidancePlugin);
private MinimalPermissionsGuidancePluginConfiguration _configuration = new();
private readonly Option<string?> _filePath;

public MinimalPermissionsGuidancePlugin()
{
_filePath = new Option<string?>("--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,
Expand All @@ -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())
Expand Down Expand Up @@ -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<string, string>(methodAndUrl.Item1, GetTokenizedUrl(methodAndUrl.Item2));
}

Expand All @@ -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);
}
}
Expand All @@ -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);
}
}
Expand All @@ -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<MinimalPermissionsInfo>();

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);
}
}

Expand All @@ -139,7 +254,8 @@ private Tuple<string, string>[] GetRequestsFromBatch(string batchBody, string gr
return requests.ToArray();
}

try {
try
{
var batch = JsonSerializer.Deserialize<GraphBatchRequestPayload>(batchBody);
if (batch == null)
{
Expand All @@ -148,16 +264,17 @@ private Tuple<string, string>[] 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<string, string>(method, GetTokenizedUrl(absoluteUrl)));
}
catch {}
catch { }
}
}
catch {}
catch { }

return requests.ToArray();
}
Expand All @@ -168,7 +285,8 @@ private Tuple<string, string>[] GetRequestsFromBatch(string batchBody, string gr
/// If it can't get the permissions, returns PermissionType.Application for Item1
/// and an empty array for Item2.
/// </summary>
private Tuple<PermissionsType, string[]> GetPermissionsAndType(RequestLog request) {
private Tuple<PermissionsType, string[]> GetPermissionsAndType(RequestLog request)
{
var authHeader = request.Context?.Session.HttpClient.Request.Headers.GetFirstHeader("Authorization");
if (authHeader == null)
{
Expand All @@ -182,7 +300,8 @@ private Tuple<PermissionsType, string[]> GetPermissionsAndType(RequestLog reques
return new Tuple<PermissionsType, string[]>(PermissionsType.Application, Array.Empty<string>());
}

try {
try
{
var handler = new JwtSecurityTokenHandler();
var jwtSecurityToken = handler.ReadJwtToken(token);

Expand All @@ -204,11 +323,13 @@ private Tuple<PermissionsType, string[]> GetPermissionsAndType(RequestLog reques
return new Tuple<PermissionsType, string[]>(PermissionsType.Application, roles);
}
}
else {
else
{
return new Tuple<PermissionsType, string[]>(PermissionsType.Delegated, scopeClaim.Value.Split(' '));
}
}
catch {
catch
{
return new Tuple<PermissionsType, string[]>(PermissionsType.Application, Array.Empty<string>());
}
}
Expand All @@ -223,10 +344,17 @@ private string GetScopeTypeString(PermissionsType scopeType)
};
}

private async Task EvaluateMinimalScopes(IEnumerable<Tuple<string, string>> endpoints, string[] permissionsFromAccessToken, PermissionsType scopeType)
private async Task EvaluateMinimalScopes(IEnumerable<Tuple<string, string>> 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)}";
Expand All @@ -241,31 +369,37 @@ private async Task EvaluateMinimalScopes(IEnumerable<Tuple<string, string>> endp
_logger?.LogDebug($"Response:{Environment.NewLine}{content}");

var resultsAndErrors = JsonSerializer.Deserialize<ResultsAndErrors>(content);
var minimalScopes = resultsAndErrors?.Results?.Select(p => p.Value).ToArray() ?? Array.Empty<string>();
var minimalPermissions = resultsAndErrors?.Results?.Select(p => p.Value).ToArray() ?? Array.Empty<string>();
var errors = resultsAndErrors?.Errors?.Select(e => $"- {e.Url} ({e.Message})") ?? Array.Empty<string>();
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())
Expand Down
Loading