Skip to content

Commit c03208c

Browse files
Fix permissions for path-addressed drive items (#1919)
Canonicalize Graph drive item paths to their equivalent ID-addressed routes before querying the permissions service. Closes #1918. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8a9b0692-9775-4433-b9b0-b65f1d6ff25c
1 parent 7666ac4 commit c03208c

2 files changed

Lines changed: 128 additions & 0 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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.Plugins.Utils;
6+
using Xunit;
7+
8+
namespace DevProxy.Integration.Tests;
9+
10+
public class GraphUtilsTests
11+
{
12+
[Theory]
13+
[InlineData(
14+
"https://graph.microsoft.com/v1.0/drive/root:/Documents/report.xlsx:/workbook/worksheets",
15+
"/me/drive/items/{id}/workbook/worksheets")]
16+
[InlineData(
17+
"https://graph.microsoft.com/v1.0/drives/drive-id/root:/Documents/report.xlsx",
18+
"/drives/{drives-id}/items/{id}")]
19+
[InlineData(
20+
"https://graph.microsoft.com/v1.0/groups/group-id/drive/root:/Documents/report.xlsx:/content",
21+
"/groups/{groups-id}/drive/items/{id}/content")]
22+
[InlineData(
23+
"https://graph.microsoft.com/v1.0/me/drive/root:/Documents/report.xlsx:/permissions",
24+
"/me/drive/items/{id}/permissions")]
25+
[InlineData(
26+
"https://graph.microsoft.com/v1.0/sites/{site-id}/drive/root:/Documentos/informe.pdf:/content",
27+
"/sites/{site-id}/drive/items/{id}/content")]
28+
[InlineData(
29+
"https://graph.microsoft.com/beta/users/user-id/drive/root:/Documents/report.xlsx:/children",
30+
"/users/{users-id}/drive/items/{id}/children")]
31+
[InlineData(
32+
"https://graph.microsoft.com/v1.0/drives/drive-id/items/parent-id:/Documents/report.xlsx:/createUploadSession",
33+
"/drives/{drives-id}/items/{id}/createUploadSession")]
34+
public void GetTokenizedUrl_RootRelativePath_ReturnsIdAddressedUrl(string url, string expected) =>
35+
Assert.Equal(expected, GraphUtils.GetTokenizedUrl(url));
36+
37+
[Theory]
38+
[InlineData("My%20Files/quarterly-report%20%231.xlsx")]
39+
[InlineData("O'Brien/estimate%25s.xlsx")]
40+
[InlineData("Break%23Out/saved_game%5B1%5D.bin")]
41+
public void GetTokenizedUrl_PathWithSpecialCharacters_ReturnsIdAddressedUrl(string itemPath)
42+
{
43+
var url = $"https://graph.microsoft.com/v1.0/me/drive/root:/{itemPath}:/content";
44+
45+
Assert.Equal("/me/drive/items/{id}/content", GraphUtils.GetTokenizedUrl(url));
46+
}
47+
48+
[Theory]
49+
[InlineData(
50+
"https://graph.microsoft.com/v1.0/me/drive/items/parent-id:/Documents/report.txt:/content",
51+
"/me/drive/items/{id}/content")]
52+
[InlineData(
53+
"https://graph.microsoft.com/v1.0/me/drive/special/documents:/report.txt:/content",
54+
"/me/drive/items/{id}/content")]
55+
public void GetTokenizedUrl_ItemRelativePath_ReturnsIdAddressedUrl(string url, string expected) =>
56+
Assert.Equal(expected, GraphUtils.GetTokenizedUrl(url));
57+
58+
[Fact]
59+
public void GetTokenizedUrl_NonPathAddressedUrl_PreservesExistingTokenization() =>
60+
Assert.Equal(
61+
"/sites/{sites-id}/drive/items/{items-id}/content",
62+
GraphUtils.GetTokenizedUrl("https://graph.microsoft.com/v1.0/sites/site-id/drive/items/item-id/content"));
63+
64+
[Fact]
65+
public void GetRequestsFromBatch_PathAddressedRequest_ReturnsIdAddressedUrl()
66+
{
67+
const string batchBody =
68+
"""
69+
{
70+
"requests": [
71+
{
72+
"id": "1",
73+
"method": "PUT",
74+
"url": "/sites/site-id/drive/root:/Documents/report.txt:/content"
75+
},
76+
{
77+
"id": "2",
78+
"method": "GET",
79+
"url": "/sites/site-id/drive/items/item-id"
80+
}
81+
]
82+
}
83+
""";
84+
85+
var requests = GraphUtils.GetRequestsFromBatch(batchBody, "v1.0", "graph.microsoft.com");
86+
87+
Assert.Collection(
88+
requests,
89+
request =>
90+
{
91+
Assert.Equal("PUT", request.Method);
92+
Assert.Equal("/sites/{sites-id}/drive/items/{id}/content", request.Url);
93+
},
94+
request =>
95+
{
96+
Assert.Equal("GET", request.Method);
97+
Assert.Equal("/sites/{sites-id}/drive/items/{items-id}", request.Url);
98+
});
99+
}
100+
}

‎DevProxy.Plugins/Utils/GraphUtils.cs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@
99
using Microsoft.Extensions.Logging;
1010
using System.Net.Http.Json;
1111
using System.Text.Json;
12+
using System.Text.RegularExpressions;
1213

1314
namespace DevProxy.Plugins.Utils;
1415

1516
sealed class GraphUtils(
1617
HttpClient httpClient,
1718
ILogger<GraphUtils> logger)
1819
{
20+
private static readonly Regex DriveItemPathRegex = new(
21+
@"^/(?<version>v1\.0|beta)/(?<drive>drive|drives/[^/]+|groups/[^/]+/drive|me/drive|sites/[^/]+/drive|users/[^/]+/drive)/(?:root|items/[^/]+|special/[^/]+):/",
22+
RegexOptions.IgnoreCase);
23+
1924
private readonly HttpClient _httpClient = httpClient;
2025
private readonly ILogger _logger = logger;
2126

@@ -115,10 +120,33 @@ internal async Task<IEnumerable<string>> UpdateUserScopesAsync(IEnumerable<strin
115120

116121
internal static string GetTokenizedUrl(string absoluteUrl)
117122
{
123+
absoluteUrl = CanonicalizeDriveItemPathUrl(absoluteUrl);
118124
var sanitizedUrl = ProxyUtils.SanitizeUrl(absoluteUrl);
119125
return "/" + string.Concat(new Uri(sanitizedUrl).Segments.Skip(2).Select(Uri.UnescapeDataString));
120126
}
121127

128+
private static string CanonicalizeDriveItemPathUrl(string absoluteUrl)
129+
{
130+
var uri = new Uri(absoluteUrl);
131+
var path = Uri.UnescapeDataString(uri.AbsolutePath);
132+
var match = DriveItemPathRegex.Match(path);
133+
if (!match.Success)
134+
{
135+
return absoluteUrl;
136+
}
137+
138+
var suffixDelimiterIndex = path.IndexOf(":/", match.Index + match.Length, StringComparison.Ordinal);
139+
var suffix = suffixDelimiterIndex < 0 ? string.Empty : path[(suffixDelimiterIndex + 1)..];
140+
var drive = match.Groups["drive"].Value;
141+
if (drive.Equals("drive", StringComparison.OrdinalIgnoreCase))
142+
{
143+
drive = "me/drive";
144+
}
145+
146+
var canonicalPath = $"/{match.Groups["version"].Value}/{drive}/items/{{id}}{suffix}";
147+
return $"{uri.GetLeftPart(UriPartial.Authority)}{canonicalPath}{uri.Query}";
148+
}
149+
122150
internal static MethodAndUrl[] GetRequestsFromBatch(string batchBody, string graphVersion, string graphHostName)
123151
{
124152
var requests = new List<MethodAndUrl>();

0 commit comments

Comments
 (0)