Skip to content

Commit f38a812

Browse files
authoredSep 4, 2023
Merge pull request #1303 from json-api-dotnet/auth-scopes-example
Add example for scopes-based authorization
2 parents 396123c + 9691a04 commit f38a812

16 files changed

+1664
-3
lines changed
 

‎src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public override QueryExpression DefaultVisit(QueryExpression expression, TArgume
3434
return null;
3535
}
3636

37-
public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument)
37+
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument)
3838
{
3939
return expression;
4040
}

‎test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ private sealed class FilterWalker : QueryExpressionRewriter<object?>
180180
{
181181
public bool HasFilterOnArchivedAt { get; private set; }
182182

183-
public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
183+
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
184184
{
185185
if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt))
186186
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
9+
public sealed class Actor : Identifiable<long>
10+
{
11+
[Attr]
12+
public string Name { get; set; } = null!;
13+
14+
[Attr]
15+
public DateTime BornAt { get; set; }
16+
17+
[HasMany]
18+
public ISet<Movie> ActsIn { get; set; } = new HashSet<Movie>();
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
using System.Text;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Middleware;
4+
using JsonApiDotNetCore.Resources;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
using Microsoft.AspNetCore.Http;
7+
using Microsoft.Extensions.Primitives;
8+
9+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
10+
11+
internal sealed class AuthScopeSet
12+
{
13+
private const StringSplitOptions ScopesHeaderSplitOptions = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries;
14+
15+
public const string ScopesHeaderName = "X-Auth-Scopes";
16+
17+
private readonly Dictionary<string, Permission> _scopes = new();
18+
19+
public static AuthScopeSet GetRequestedScopes(IHeaderDictionary requestHeaders)
20+
{
21+
var requestedScopes = new AuthScopeSet();
22+
23+
// In a real application, the scopes would be read from the signed ticket in the Authorization HTTP header.
24+
// For simplicity, this sample allows the client to send them directly, which is obviously insecure.
25+
26+
if (requestHeaders.TryGetValue(ScopesHeaderName, out StringValues headerValue))
27+
{
28+
foreach (string scopeValue in headerValue.ToString().Split(' ', ScopesHeaderSplitOptions))
29+
{
30+
string[] scopeParts = scopeValue.Split(':', 2, ScopesHeaderSplitOptions);
31+
32+
if (scopeParts.Length == 2 && Enum.TryParse(scopeParts[0], true, out Permission permission) && Enum.IsDefined(permission))
33+
{
34+
requestedScopes.Include(scopeParts[1], permission);
35+
}
36+
}
37+
}
38+
39+
return requestedScopes;
40+
}
41+
42+
public void IncludeFrom(IJsonApiRequest request, ITargetedFields targetedFields)
43+
{
44+
Permission permission = request.IsReadOnly ? Permission.Read : Permission.Write;
45+
46+
if (request.PrimaryResourceType != null)
47+
{
48+
Include(request.PrimaryResourceType, permission);
49+
}
50+
51+
if (request.SecondaryResourceType != null)
52+
{
53+
Include(request.SecondaryResourceType, permission);
54+
}
55+
56+
if (request.Relationship != null)
57+
{
58+
Include(request.Relationship, permission);
59+
}
60+
61+
foreach (RelationshipAttribute relationship in targetedFields.Relationships)
62+
{
63+
Include(relationship, permission);
64+
}
65+
}
66+
67+
public void Include(ResourceType resourceType, Permission permission)
68+
{
69+
Include(resourceType.PublicName, permission);
70+
}
71+
72+
public void Include(RelationshipAttribute relationship, Permission permission)
73+
{
74+
Include(relationship.LeftType, permission);
75+
Include(relationship.RightType, permission);
76+
}
77+
78+
private void Include(string name, Permission permission)
79+
{
80+
// Unify with existing entries. For example, adding read:movies when write:movies already exists is a no-op.
81+
82+
if (_scopes.TryGetValue(name, out Permission value))
83+
{
84+
if (value >= permission)
85+
{
86+
return;
87+
}
88+
}
89+
90+
_scopes[name] = permission;
91+
}
92+
93+
public bool ContainsAll(AuthScopeSet other)
94+
{
95+
foreach (string otherName in other._scopes.Keys)
96+
{
97+
if (!_scopes.TryGetValue(otherName, out Permission thisPermission))
98+
{
99+
return false;
100+
}
101+
102+
if (thisPermission < other._scopes[otherName])
103+
{
104+
return false;
105+
}
106+
}
107+
108+
return true;
109+
}
110+
111+
public override string ToString()
112+
{
113+
var builder = new StringBuilder();
114+
115+
foreach ((string name, Permission permission) in _scopes.OrderBy(scope => scope.Key))
116+
{
117+
if (builder.Length > 0)
118+
{
119+
builder.Append(' ');
120+
}
121+
122+
builder.Append($"{permission.ToString().ToLowerInvariant()}:{name}");
123+
}
124+
125+
return builder.ToString();
126+
}
127+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
9+
public sealed class Genre : Identifiable<long>
10+
{
11+
[Attr]
12+
public string Name { get; set; } = null!;
13+
14+
[HasMany]
15+
public ISet<Movie> Movies { get; set; } = new HashSet<Movie>();
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Resources;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")]
9+
public sealed class Movie : Identifiable<long>
10+
{
11+
[Attr]
12+
public string Title { get; set; } = null!;
13+
14+
[Attr]
15+
public int ReleaseYear { get; set; }
16+
17+
[Attr]
18+
public int DurationInSeconds { get; set; }
19+
20+
[HasOne]
21+
public Genre Genre { get; set; } = null!;
22+
23+
[HasMany]
24+
public ISet<Actor> Cast { get; set; } = new HashSet<Actor>();
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Net;
2+
using JsonApiDotNetCore.AtomicOperations;
3+
using JsonApiDotNetCore.Configuration;
4+
using JsonApiDotNetCore.Controllers;
5+
using JsonApiDotNetCore.Middleware;
6+
using JsonApiDotNetCore.Resources;
7+
using JsonApiDotNetCore.Serialization.Objects;
8+
using Microsoft.AspNetCore.Mvc;
9+
using Microsoft.Extensions.Logging;
10+
11+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
12+
13+
public sealed class OperationsController : JsonApiOperationsController
14+
{
15+
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor,
16+
IJsonApiRequest request, ITargetedFields targetedFields)
17+
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
18+
{
19+
}
20+
21+
public override async Task<IActionResult> PostOperationsAsync(IList<OperationContainer> operations, CancellationToken cancellationToken)
22+
{
23+
AuthScopeSet requestedScopes = AuthScopeSet.GetRequestedScopes(HttpContext.Request.Headers);
24+
AuthScopeSet requiredScopes = GetRequiredScopes(operations);
25+
26+
if (!requestedScopes.ContainsAll(requiredScopes))
27+
{
28+
return Error(new ErrorObject(HttpStatusCode.Unauthorized)
29+
{
30+
Title = "Insufficient permissions to perform this request.",
31+
Detail = $"Performing this request requires the following scopes: {requiredScopes}.",
32+
Source = new ErrorSource
33+
{
34+
Header = AuthScopeSet.ScopesHeaderName
35+
}
36+
});
37+
}
38+
39+
return await base.PostOperationsAsync(operations, cancellationToken);
40+
}
41+
42+
private AuthScopeSet GetRequiredScopes(IEnumerable<OperationContainer> operations)
43+
{
44+
var requiredScopes = new AuthScopeSet();
45+
46+
foreach (OperationContainer operation in operations)
47+
{
48+
requiredScopes.IncludeFrom(operation.Request, operation.TargetedFields);
49+
}
50+
51+
return requiredScopes;
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
2+
3+
internal enum Permission
4+
{
5+
Read,
6+
7+
// Write access implicitly includes read access, because POST/PATCH in JSON:API may return the changed resource.
8+
Write
9+
}

‎test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs

+466
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
using System.Net;
2+
using System.Net.Http.Headers;
3+
using FluentAssertions;
4+
using JsonApiDotNetCore.Serialization.Objects;
5+
using TestBuildingBlocks;
6+
using Xunit;
7+
8+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
9+
10+
public sealed class ScopeReadTests : IClassFixture<IntegrationTestContext<ScopesStartup<ScopesDbContext>, ScopesDbContext>>
11+
{
12+
private const string ScopeHeaderName = "X-Auth-Scopes";
13+
private readonly IntegrationTestContext<ScopesStartup<ScopesDbContext>, ScopesDbContext> _testContext;
14+
private readonly ScopesFakers _fakers = new();
15+
16+
public ScopeReadTests(IntegrationTestContext<ScopesStartup<ScopesDbContext>, ScopesDbContext> testContext)
17+
{
18+
_testContext = testContext;
19+
20+
testContext.UseController<MoviesController>();
21+
testContext.UseController<ActorsController>();
22+
testContext.UseController<GenresController>();
23+
}
24+
25+
[Fact]
26+
public async Task Cannot_get_primary_resources_without_scopes()
27+
{
28+
// Arrange
29+
const string route = "/movies";
30+
31+
// Act
32+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
33+
34+
// Assert
35+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
36+
37+
responseDocument.Errors.ShouldHaveCount(1);
38+
39+
ErrorObject error = responseDocument.Errors[0];
40+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
41+
error.Title.Should().Be("Insufficient permissions to perform this request.");
42+
error.Detail.Should().Be("Performing this request requires the following scopes: read:movies.");
43+
error.Source.ShouldNotBeNull();
44+
error.Source.Header.Should().Be(ScopeHeaderName);
45+
}
46+
47+
[Fact]
48+
public async Task Cannot_get_primary_resources_with_incorrect_scopes()
49+
{
50+
// Arrange
51+
const string route = "/movies";
52+
53+
Action<HttpRequestHeaders> setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:actors write:genres");
54+
55+
// Act
56+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route, setRequestHeaders);
57+
58+
// Assert
59+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
60+
61+
responseDocument.Errors.ShouldHaveCount(1);
62+
63+
ErrorObject error = responseDocument.Errors[0];
64+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
65+
error.Title.Should().Be("Insufficient permissions to perform this request.");
66+
error.Detail.Should().Be("Performing this request requires the following scopes: read:movies.");
67+
error.Source.ShouldNotBeNull();
68+
error.Source.Header.Should().Be(ScopeHeaderName);
69+
}
70+
71+
[Fact]
72+
public async Task Can_get_primary_resources_with_correct_scope()
73+
{
74+
// Arrange
75+
Movie movie = _fakers.Movie.Generate();
76+
movie.Genre = _fakers.Genre.Generate();
77+
78+
await _testContext.RunOnDatabaseAsync(async dbContext =>
79+
{
80+
await dbContext.ClearTableAsync<Movie>();
81+
dbContext.Movies.Add(movie);
82+
await dbContext.SaveChangesAsync();
83+
});
84+
85+
const string route = "/movies";
86+
87+
Action<HttpRequestHeaders> setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies");
88+
89+
// Act
90+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route, setRequestHeaders);
91+
92+
// Assert
93+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
94+
95+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
96+
responseDocument.Data.ManyValue[0].Type.Should().Be("movies");
97+
responseDocument.Data.ManyValue[0].Id.Should().Be(movie.StringId);
98+
responseDocument.Data.ManyValue[0].Attributes.ShouldNotBeEmpty();
99+
responseDocument.Data.ManyValue[0].Relationships.ShouldNotBeEmpty();
100+
}
101+
102+
[Fact]
103+
public async Task Can_get_primary_resources_with_write_scope()
104+
{
105+
// Arrange
106+
Genre genre = _fakers.Genre.Generate();
107+
108+
await _testContext.RunOnDatabaseAsync(async dbContext =>
109+
{
110+
await dbContext.ClearTableAsync<Genre>();
111+
dbContext.Genres.Add(genre);
112+
await dbContext.SaveChangesAsync();
113+
});
114+
115+
const string route = "/genres";
116+
117+
Action<HttpRequestHeaders> setRequestHeaders = headers => headers.Add(ScopeHeaderName, "write:genres");
118+
119+
// Act
120+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route, setRequestHeaders);
121+
122+
// Assert
123+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
124+
125+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
126+
responseDocument.Data.ManyValue[0].Type.Should().Be("genres");
127+
responseDocument.Data.ManyValue[0].Id.Should().Be(genre.StringId);
128+
responseDocument.Data.ManyValue[0].Attributes.ShouldNotBeEmpty();
129+
responseDocument.Data.ManyValue[0].Relationships.ShouldNotBeEmpty();
130+
}
131+
132+
[Fact]
133+
public async Task Can_get_primary_resources_with_redundant_scopes()
134+
{
135+
// Arrange
136+
Actor actor = _fakers.Actor.Generate();
137+
138+
await _testContext.RunOnDatabaseAsync(async dbContext =>
139+
{
140+
await dbContext.ClearTableAsync<Actor>();
141+
dbContext.Actors.Add(actor);
142+
await dbContext.SaveChangesAsync();
143+
});
144+
145+
const string route = "/actors";
146+
147+
Action<HttpRequestHeaders> setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:genres read:actors write:movies");
148+
149+
// Act
150+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route, setRequestHeaders);
151+
152+
// Assert
153+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
154+
155+
responseDocument.Data.ManyValue.ShouldHaveCount(1);
156+
responseDocument.Data.ManyValue[0].Type.Should().Be("actors");
157+
responseDocument.Data.ManyValue[0].Id.Should().Be(actor.StringId);
158+
responseDocument.Data.ManyValue[0].Attributes.ShouldNotBeEmpty();
159+
responseDocument.Data.ManyValue[0].Relationships.ShouldNotBeEmpty();
160+
}
161+
162+
[Fact]
163+
public async Task Cannot_get_primary_resource_without_scopes()
164+
{
165+
// Arrange
166+
const string route = "/actors/1";
167+
168+
// Act
169+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
170+
171+
// Assert
172+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
173+
174+
responseDocument.Errors.ShouldHaveCount(1);
175+
176+
ErrorObject error = responseDocument.Errors[0];
177+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
178+
error.Title.Should().Be("Insufficient permissions to perform this request.");
179+
error.Detail.Should().Be("Performing this request requires the following scopes: read:actors.");
180+
error.Source.ShouldNotBeNull();
181+
error.Source.Header.Should().Be(ScopeHeaderName);
182+
}
183+
184+
[Fact]
185+
public async Task Cannot_get_secondary_resource_without_scopes()
186+
{
187+
// Arrange
188+
const string route = "/movies/1/genre";
189+
190+
// Act
191+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
192+
193+
// Assert
194+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
195+
196+
responseDocument.Errors.ShouldHaveCount(1);
197+
198+
ErrorObject error = responseDocument.Errors[0];
199+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
200+
error.Title.Should().Be("Insufficient permissions to perform this request.");
201+
error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies.");
202+
error.Source.ShouldNotBeNull();
203+
error.Source.Header.Should().Be(ScopeHeaderName);
204+
}
205+
206+
[Fact]
207+
public async Task Cannot_get_secondary_resources_without_scopes()
208+
{
209+
// Arrange
210+
const string route = "/genres/1/movies";
211+
212+
// Act
213+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
214+
215+
// Assert
216+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
217+
218+
responseDocument.Errors.ShouldHaveCount(1);
219+
220+
ErrorObject error = responseDocument.Errors[0];
221+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
222+
error.Title.Should().Be("Insufficient permissions to perform this request.");
223+
error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies.");
224+
error.Source.ShouldNotBeNull();
225+
error.Source.Header.Should().Be(ScopeHeaderName);
226+
}
227+
228+
[Fact]
229+
public async Task Cannot_get_ToOne_relationship_without_scopes()
230+
{
231+
// Arrange
232+
const string route = "/movies/1/relationships/genre";
233+
234+
// Act
235+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
236+
237+
// Assert
238+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
239+
240+
responseDocument.Errors.ShouldHaveCount(1);
241+
242+
ErrorObject error = responseDocument.Errors[0];
243+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
244+
error.Title.Should().Be("Insufficient permissions to perform this request.");
245+
error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies.");
246+
error.Source.ShouldNotBeNull();
247+
error.Source.Header.Should().Be(ScopeHeaderName);
248+
}
249+
250+
[Fact]
251+
public async Task Cannot_get_ToMany_relationship_without_scopes()
252+
{
253+
// Arrange
254+
const string route = "/genres/1/relationships/movies";
255+
256+
// Act
257+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
258+
259+
// Assert
260+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
261+
262+
responseDocument.Errors.ShouldHaveCount(1);
263+
264+
ErrorObject error = responseDocument.Errors[0];
265+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
266+
error.Title.Should().Be("Insufficient permissions to perform this request.");
267+
error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies.");
268+
error.Source.ShouldNotBeNull();
269+
error.Source.Header.Should().Be(ScopeHeaderName);
270+
}
271+
272+
[Fact]
273+
public async Task Cannot_include_with_insufficient_scopes()
274+
{
275+
// Arrange
276+
const string route = "/movies?include=genre,cast";
277+
278+
Action<HttpRequestHeaders> setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies");
279+
280+
// Act
281+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route, setRequestHeaders);
282+
283+
// Assert
284+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
285+
286+
responseDocument.Errors.ShouldHaveCount(1);
287+
288+
ErrorObject error = responseDocument.Errors[0];
289+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
290+
error.Title.Should().Be("Insufficient permissions to perform this request.");
291+
error.Detail.Should().Be("Performing this request requires the following scopes: read:actors read:genres read:movies.");
292+
error.Source.ShouldNotBeNull();
293+
error.Source.Header.Should().Be(ScopeHeaderName);
294+
}
295+
296+
[Fact]
297+
public async Task Cannot_filter_with_insufficient_scopes()
298+
{
299+
// Arrange
300+
const string route = "/movies?filter=and(has(cast),equals(genre.name,'some'))";
301+
302+
Action<HttpRequestHeaders> setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies");
303+
304+
// Act
305+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route, setRequestHeaders);
306+
307+
// Assert
308+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
309+
310+
responseDocument.Errors.ShouldHaveCount(1);
311+
312+
ErrorObject error = responseDocument.Errors[0];
313+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
314+
error.Title.Should().Be("Insufficient permissions to perform this request.");
315+
error.Detail.Should().Be("Performing this request requires the following scopes: read:actors read:genres read:movies.");
316+
error.Source.ShouldNotBeNull();
317+
error.Source.Header.Should().Be(ScopeHeaderName);
318+
}
319+
320+
[Fact]
321+
public async Task Cannot_sort_with_insufficient_scopes()
322+
{
323+
// Arrange
324+
const string route = "/movies?sort=count(cast),genre.name";
325+
326+
Action<HttpRequestHeaders> setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies");
327+
328+
// Act
329+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route, setRequestHeaders);
330+
331+
// Assert
332+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized);
333+
334+
responseDocument.Errors.ShouldHaveCount(1);
335+
336+
ErrorObject error = responseDocument.Errors[0];
337+
error.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
338+
error.Title.Should().Be("Insufficient permissions to perform this request.");
339+
error.Detail.Should().Be("Performing this request requires the following scopes: read:actors read:genres read:movies.");
340+
error.Source.ShouldNotBeNull();
341+
error.Source.Header.Should().Be(ScopeHeaderName);
342+
}
343+
}

‎test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeWriteTests.cs

+434
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
using System.Net;
2+
using JetBrains.Annotations;
3+
using JsonApiDotNetCore.Middleware;
4+
using JsonApiDotNetCore.Queries;
5+
using JsonApiDotNetCore.Queries.Expressions;
6+
using JsonApiDotNetCore.Resources;
7+
using JsonApiDotNetCore.Resources.Annotations;
8+
using JsonApiDotNetCore.Serialization.Objects;
9+
using Microsoft.AspNetCore.Mvc;
10+
using Microsoft.AspNetCore.Mvc.Filters;
11+
using Microsoft.Extensions.DependencyInjection;
12+
13+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
14+
15+
// Implements IActionFilter instead of IAuthorizationFilter because it needs to execute *after* parsing query string parameters.
16+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
17+
internal sealed class ScopesAuthorizationFilter : IActionFilter
18+
{
19+
public void OnActionExecuting(ActionExecutingContext context)
20+
{
21+
var request = context.HttpContext.RequestServices.GetRequiredService<IJsonApiRequest>();
22+
var targetedFields = context.HttpContext.RequestServices.GetRequiredService<ITargetedFields>();
23+
var constraintProviders = context.HttpContext.RequestServices.GetRequiredService<IEnumerable<IQueryConstraintProvider>>();
24+
25+
if (request.Kind == EndpointKind.AtomicOperations)
26+
{
27+
// Handled in operators controller, because it requires access to the individual operations.
28+
return;
29+
}
30+
31+
AuthScopeSet requestedScopes = AuthScopeSet.GetRequestedScopes(context.HttpContext.Request.Headers);
32+
AuthScopeSet requiredScopes = GetRequiredScopes(request, targetedFields, constraintProviders);
33+
34+
if (!requestedScopes.ContainsAll(requiredScopes))
35+
{
36+
context.Result = new UnauthorizedObjectResult(new ErrorObject(HttpStatusCode.Unauthorized)
37+
{
38+
Title = "Insufficient permissions to perform this request.",
39+
Detail = $"Performing this request requires the following scopes: {requiredScopes}.",
40+
Source = new ErrorSource
41+
{
42+
Header = AuthScopeSet.ScopesHeaderName
43+
}
44+
});
45+
}
46+
}
47+
48+
public void OnActionExecuted(ActionExecutedContext context)
49+
{
50+
}
51+
52+
private AuthScopeSet GetRequiredScopes(IJsonApiRequest request, ITargetedFields targetedFields, IEnumerable<IQueryConstraintProvider> constraintProviders)
53+
{
54+
var requiredScopes = new AuthScopeSet();
55+
requiredScopes.IncludeFrom(request, targetedFields);
56+
57+
var walker = new QueryStringWalker(requiredScopes);
58+
walker.IncludeScopesFrom(constraintProviders);
59+
60+
return requiredScopes;
61+
}
62+
63+
private sealed class QueryStringWalker : QueryExpressionRewriter<object?>
64+
{
65+
private readonly AuthScopeSet _authScopeSet;
66+
67+
public QueryStringWalker(AuthScopeSet authScopeSet)
68+
{
69+
_authScopeSet = authScopeSet;
70+
}
71+
72+
public void IncludeScopesFrom(IEnumerable<IQueryConstraintProvider> constraintProviders)
73+
{
74+
foreach (ExpressionInScope constraint in constraintProviders.SelectMany(provider => provider.GetConstraints()))
75+
{
76+
Visit(constraint.Expression, null);
77+
}
78+
}
79+
80+
public override QueryExpression VisitIncludeElement(IncludeElementExpression expression, object? argument)
81+
{
82+
_authScopeSet.Include(expression.Relationship, Permission.Read);
83+
84+
return base.VisitIncludeElement(expression, argument);
85+
}
86+
87+
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
88+
{
89+
foreach (ResourceFieldAttribute field in expression.Fields)
90+
{
91+
if (field is RelationshipAttribute relationship)
92+
{
93+
_authScopeSet.Include(relationship, Permission.Read);
94+
}
95+
else
96+
{
97+
_authScopeSet.Include(field.Type, Permission.Read);
98+
}
99+
}
100+
101+
return base.VisitResourceFieldChain(expression, argument);
102+
}
103+
}
104+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using JetBrains.Annotations;
2+
using Microsoft.EntityFrameworkCore;
3+
using TestBuildingBlocks;
4+
5+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
public sealed class ScopesDbContext : TestableDbContext
9+
{
10+
public DbSet<Movie> Movies => Set<Movie>();
11+
public DbSet<Actor> Actors => Set<Actor>();
12+
public DbSet<Genre> Genres => Set<Genre>();
13+
14+
public ScopesDbContext(DbContextOptions<ScopesDbContext> options)
15+
: base(options)
16+
{
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Bogus;
2+
using TestBuildingBlocks;
3+
4+
// @formatter:wrap_chained_method_calls chop_if_long
5+
// @formatter:wrap_before_first_method_call true
6+
7+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
8+
9+
internal sealed class ScopesFakers : FakerContainer
10+
{
11+
private readonly Lazy<Faker<Movie>> _lazyMovieFaker = new(() => new Faker<Movie>()
12+
.UseSeed(GetFakerSeed())
13+
.RuleFor(movie => movie.Title, faker => faker.Random.Words())
14+
.RuleFor(movie => movie.ReleaseYear, faker => faker.Random.Int(1900, 2050))
15+
.RuleFor(movie => movie.DurationInSeconds, faker => faker.Random.Int(300, 14400)));
16+
17+
private readonly Lazy<Faker<Actor>> _lazyActorFaker = new(() => new Faker<Actor>()
18+
.UseSeed(GetFakerSeed())
19+
.RuleFor(actor => actor.Name, faker => faker.Person.FullName)
20+
.RuleFor(actor => actor.BornAt, faker => faker.Date.Past()));
21+
22+
private readonly Lazy<Faker<Genre>> _lazyGenreFaker = new(() => new Faker<Genre>()
23+
.UseSeed(GetFakerSeed())
24+
.RuleFor(genre => genre.Name, faker => faker.Random.Word()));
25+
26+
public Faker<Movie> Movie => _lazyMovieFaker.Value;
27+
public Faker<Actor> Actor => _lazyActorFaker.Value;
28+
public Faker<Genre> Genre => _lazyGenreFaker.Value;
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Configuration;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using TestBuildingBlocks;
5+
6+
namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes;
7+
8+
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
9+
public sealed class ScopesStartup<TDbContext> : TestableStartup<TDbContext>
10+
where TDbContext : TestableDbContext
11+
{
12+
public override void ConfigureServices(IServiceCollection services)
13+
{
14+
IMvcCoreBuilder mvcBuilder = services.AddMvcCore(options => options.Filters.Add<ScopesAuthorizationFilter>(int.MaxValue));
15+
16+
services.AddJsonApi<TDbContext>(SetJsonApiOptions, mvcBuilder: mvcBuilder);
17+
}
18+
}

‎test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public override QueryExpression DefaultVisit(QueryExpression expression, object?
1818
return base.VisitComparison(expression, argument);
1919
}
2020

21-
public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
21+
public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument)
2222
{
2323
Capture(expression);
2424
return base.VisitResourceFieldChain(expression, argument);

0 commit comments

Comments
 (0)
Please sign in to comment.