From 9691a047bb7e861ed1f7aaa69c0f5baff3446ba2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 3 Sep 2023 23:50:50 +0200 Subject: [PATCH] Add example for scopes-based authorization --- .../Expressions/QueryExpressionRewriter.cs | 2 +- .../TelevisionBroadcastDefinition.cs | 2 +- .../Authorization/Scopes/Actor.cs | 19 + .../Authorization/Scopes/AuthScopeSet.cs | 127 +++++ .../Authorization/Scopes/Genre.cs | 16 + .../Authorization/Scopes/Movie.cs | 25 + .../Scopes/OperationsController.cs | 53 ++ .../Authorization/Scopes/Permission.cs | 9 + .../Scopes/ScopeOperationsTests.cs | 466 ++++++++++++++++++ .../Authorization/Scopes/ScopeReadTests.cs | 343 +++++++++++++ .../Authorization/Scopes/ScopeWriteTests.cs | 434 ++++++++++++++++ .../Scopes/ScopesAuthorizationFilter.cs | 104 ++++ .../Authorization/Scopes/ScopesDbContext.cs | 18 + .../Authorization/Scopes/ScopesFakers.cs | 29 ++ .../Authorization/Scopes/ScopesStartup.cs | 18 + .../TestableQueryExpressionRewriter.cs | 2 +- 16 files changed, 1664 insertions(+), 3 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Actor.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Genre.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Movie.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Permission.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeReadTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeWriteTests.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesDbContext.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesFakers.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesStartup.cs diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index a8e87f5db6..4439e37c21 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -34,7 +34,7 @@ public override QueryExpression DefaultVisit(QueryExpression expression, TArgume return null; } - public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) { return expression; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index ddac26d61f..19a0208b17 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -180,7 +180,7 @@ private sealed class FilterWalker : QueryExpressionRewriter { public bool HasFilterOnArchivedAt { get; private set; } - public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) { if (expression.Fields[0].Property.Name == nameof(TelevisionBroadcast.ArchivedAt)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Actor.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Actor.cs new file mode 100644 index 0000000000..262fab70f1 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Actor.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")] +public sealed class Actor : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr] + public DateTime BornAt { get; set; } + + [HasMany] + public ISet ActsIn { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs new file mode 100644 index 0000000000..3a99d3c015 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/AuthScopeSet.cs @@ -0,0 +1,127 @@ +using System.Text; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +internal sealed class AuthScopeSet +{ + private const StringSplitOptions ScopesHeaderSplitOptions = StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries; + + public const string ScopesHeaderName = "X-Auth-Scopes"; + + private readonly Dictionary _scopes = new(); + + public static AuthScopeSet GetRequestedScopes(IHeaderDictionary requestHeaders) + { + var requestedScopes = new AuthScopeSet(); + + // In a real application, the scopes would be read from the signed ticket in the Authorization HTTP header. + // For simplicity, this sample allows the client to send them directly, which is obviously insecure. + + if (requestHeaders.TryGetValue(ScopesHeaderName, out StringValues headerValue)) + { + foreach (string scopeValue in headerValue.ToString().Split(' ', ScopesHeaderSplitOptions)) + { + string[] scopeParts = scopeValue.Split(':', 2, ScopesHeaderSplitOptions); + + if (scopeParts.Length == 2 && Enum.TryParse(scopeParts[0], true, out Permission permission) && Enum.IsDefined(permission)) + { + requestedScopes.Include(scopeParts[1], permission); + } + } + } + + return requestedScopes; + } + + public void IncludeFrom(IJsonApiRequest request, ITargetedFields targetedFields) + { + Permission permission = request.IsReadOnly ? Permission.Read : Permission.Write; + + if (request.PrimaryResourceType != null) + { + Include(request.PrimaryResourceType, permission); + } + + if (request.SecondaryResourceType != null) + { + Include(request.SecondaryResourceType, permission); + } + + if (request.Relationship != null) + { + Include(request.Relationship, permission); + } + + foreach (RelationshipAttribute relationship in targetedFields.Relationships) + { + Include(relationship, permission); + } + } + + public void Include(ResourceType resourceType, Permission permission) + { + Include(resourceType.PublicName, permission); + } + + public void Include(RelationshipAttribute relationship, Permission permission) + { + Include(relationship.LeftType, permission); + Include(relationship.RightType, permission); + } + + private void Include(string name, Permission permission) + { + // Unify with existing entries. For example, adding read:movies when write:movies already exists is a no-op. + + if (_scopes.TryGetValue(name, out Permission value)) + { + if (value >= permission) + { + return; + } + } + + _scopes[name] = permission; + } + + public bool ContainsAll(AuthScopeSet other) + { + foreach (string otherName in other._scopes.Keys) + { + if (!_scopes.TryGetValue(otherName, out Permission thisPermission)) + { + return false; + } + + if (thisPermission < other._scopes[otherName]) + { + return false; + } + } + + return true; + } + + public override string ToString() + { + var builder = new StringBuilder(); + + foreach ((string name, Permission permission) in _scopes.OrderBy(scope => scope.Key)) + { + if (builder.Length > 0) + { + builder.Append(' '); + } + + builder.Append($"{permission.ToString().ToLowerInvariant()}:{name}"); + } + + return builder.ToString(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Genre.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Genre.cs new file mode 100644 index 0000000000..f5bcba8fe2 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Genre.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")] +public sealed class Genre : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [HasMany] + public ISet Movies { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Movie.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Movie.cs new file mode 100644 index 0000000000..4e52ff2728 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Movie.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes")] +public sealed class Movie : Identifiable +{ + [Attr] + public string Title { get; set; } = null!; + + [Attr] + public int ReleaseYear { get; set; } + + [Attr] + public int DurationInSeconds { get; set; } + + [HasOne] + public Genre Genre { get; set; } = null!; + + [HasMany] + public ISet Cast { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs new file mode 100644 index 0000000000..bfdf4aaa94 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs @@ -0,0 +1,53 @@ +using System.Net; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +public sealed class OperationsController : JsonApiOperationsController +{ + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, + IJsonApiRequest request, ITargetedFields targetedFields) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) + { + } + + public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) + { + AuthScopeSet requestedScopes = AuthScopeSet.GetRequestedScopes(HttpContext.Request.Headers); + AuthScopeSet requiredScopes = GetRequiredScopes(operations); + + if (!requestedScopes.ContainsAll(requiredScopes)) + { + return Error(new ErrorObject(HttpStatusCode.Unauthorized) + { + Title = "Insufficient permissions to perform this request.", + Detail = $"Performing this request requires the following scopes: {requiredScopes}.", + Source = new ErrorSource + { + Header = AuthScopeSet.ScopesHeaderName + } + }); + } + + return await base.PostOperationsAsync(operations, cancellationToken); + } + + private AuthScopeSet GetRequiredScopes(IEnumerable operations) + { + var requiredScopes = new AuthScopeSet(); + + foreach (OperationContainer operation in operations) + { + requiredScopes.IncludeFrom(operation.Request, operation.TargetedFields); + } + + return requiredScopes; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Permission.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Permission.cs new file mode 100644 index 0000000000..5cca795950 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/Permission.cs @@ -0,0 +1,9 @@ +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +internal enum Permission +{ + Read, + + // Write access implicitly includes read access, because POST/PATCH in JSON:API may return the changed resource. + Write +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs new file mode 100644 index 0000000000..57af5bce7a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs @@ -0,0 +1,466 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +public sealed class ScopeOperationsTests : IClassFixture, ScopesDbContext>> +{ + private const string ScopeHeaderName = "X-Auth-Scopes"; + private readonly IntegrationTestContext, ScopesDbContext> _testContext; + private readonly ScopesFakers _fakers = new(); + + public ScopeOperationsTests(IntegrationTestContext, ScopesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Cannot_create_resources_without_scopes() + { + // Arrange + Genre newGenre = _fakers.Genre.Generate(); + Movie newMovie = _fakers.Movie.Generate(); + + const string genreLocalId = "genre-1"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "genres", + lid = genreLocalId, + attributes = new + { + name = newGenre.Name + } + } + }, + new + { + op = "add", + data = new + { + type = "movies", + attributes = new + { + title = newMovie.Title, + releaseYear = newMovie.ReleaseYear, + durationInSeconds = newMovie.DurationInSeconds + }, + relationships = new + { + genre = new + { + data = new + { + type = "genres", + lid = genreLocalId + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_create_resource_with_read_scope() + { + // Arrange + Genre newGenre = _fakers.Genre.Generate(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "genres", + attributes = new + { + name = newGenre.Name + } + } + } + } + }; + + const string route = "/operations"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:genres"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody, setRequestHeaders: setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:genres."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resources_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.Generate().Title; + DateTime newBornAt = _fakers.Actor.Generate().BornAt; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "update", + data = new + { + type = "movies", + id = "1", + attributes = new + { + title = newTitle + } + } + }, + new + { + op = "update", + data = new + { + type = "actors", + id = "1", + attributes = new + { + bornAt = newBornAt + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resource_with_relationships_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.Generate().Title; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "movies", + id = "1", + attributes = new + { + title = newTitle + }, + relationships = new + { + cast = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_delete_resources_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "genres", + id = "1" + } + }, + new + { + op = "remove", + @ref = new + { + type = "actors", + id = "1" + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:genres."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_ToOne_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "movies", + id = "1", + relationship = "genre" + }, + data = (object?)null + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "movies", + id = "1", + relationship = "cast" + }, + data = Array.Empty() + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "movies", + id = "1", + relationship = "cast" + }, + data = Array.Empty() + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "movies", + id = "1", + relationship = "cast" + }, + data = Array.Empty() + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeReadTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeReadTests.cs new file mode 100644 index 0000000000..98e5ccb7af --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeReadTests.cs @@ -0,0 +1,343 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +public sealed class ScopeReadTests : IClassFixture, ScopesDbContext>> +{ + private const string ScopeHeaderName = "X-Auth-Scopes"; + private readonly IntegrationTestContext, ScopesDbContext> _testContext; + private readonly ScopesFakers _fakers = new(); + + public ScopeReadTests(IntegrationTestContext, ScopesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + } + + [Fact] + public async Task Cannot_get_primary_resources_without_scopes() + { + // Arrange + const string route = "/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_primary_resources_with_incorrect_scopes() + { + // Arrange + const string route = "/movies"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:actors write:genres"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Can_get_primary_resources_with_correct_scope() + { + // Arrange + Movie movie = _fakers.Movie.Generate(); + movie.Genre = _fakers.Genre.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Movies.Add(movie); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/movies"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("movies"); + responseDocument.Data.ManyValue[0].Id.Should().Be(movie.StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue[0].Relationships.ShouldNotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resources_with_write_scope() + { + // Arrange + Genre genre = _fakers.Genre.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Genres.Add(genre); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/genres"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "write:genres"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("genres"); + responseDocument.Data.ManyValue[0].Id.Should().Be(genre.StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue[0].Relationships.ShouldNotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resources_with_redundant_scopes() + { + // Arrange + Actor actor = _fakers.Actor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Actors.Add(actor); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/actors"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:genres read:actors write:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("actors"); + responseDocument.Data.ManyValue[0].Id.Should().Be(actor.StringId); + responseDocument.Data.ManyValue[0].Attributes.ShouldNotBeEmpty(); + responseDocument.Data.ManyValue[0].Relationships.ShouldNotBeEmpty(); + } + + [Fact] + public async Task Cannot_get_primary_resource_without_scopes() + { + // Arrange + const string route = "/actors/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:actors."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_secondary_resource_without_scopes() + { + // Arrange + const string route = "/movies/1/genre"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_secondary_resources_without_scopes() + { + // Arrange + const string route = "/genres/1/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_ToOne_relationship_without_scopes() + { + // Arrange + const string route = "/movies/1/relationships/genre"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_get_ToMany_relationship_without_scopes() + { + // Arrange + const string route = "/genres/1/relationships/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_include_with_insufficient_scopes() + { + // Arrange + const string route = "/movies?include=genre,cast"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:actors read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_filter_with_insufficient_scopes() + { + // Arrange + const string route = "/movies?filter=and(has(cast),equals(genre.name,'some'))"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:actors read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_sort_with_insufficient_scopes() + { + // Arrange + const string route = "/movies?sort=count(cast),genre.name"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: read:actors read:genres read:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeWriteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeWriteTests.cs new file mode 100644 index 0000000000..bb9d1d05cb --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeWriteTests.cs @@ -0,0 +1,434 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +public sealed class ScopeWriteTests : IClassFixture, ScopesDbContext>> +{ + private const string ScopeHeaderName = "X-Auth-Scopes"; + private readonly IntegrationTestContext, ScopesDbContext> _testContext; + private readonly ScopesFakers _fakers = new(); + + public ScopeWriteTests(IntegrationTestContext, ScopesDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Cannot_create_resource_without_scopes() + { + // Arrange + Movie newMovie = _fakers.Movie.Generate(); + + var requestBody = new + { + data = new + { + type = "movies", + attributes = new + { + title = newMovie.Title, + releaseYear = newMovie.ReleaseYear, + durationInSeconds = newMovie.DurationInSeconds + } + } + }; + + const string route = "/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_create_resource_with_relationships_without_scopes() + { + // Arrange + Movie newMovie = _fakers.Movie.Generate(); + + var requestBody = new + { + data = new + { + type = "movies", + attributes = new + { + title = newMovie.Title, + releaseYear = newMovie.ReleaseYear, + durationInSeconds = newMovie.DurationInSeconds + }, + relationships = new + { + genre = new + { + data = new + { + type = "genres", + id = "1" + } + }, + cast = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + } + } + } + }; + + const string route = "/movies"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_create_resource_with_relationships_with_read_scopes() + { + // Arrange + Movie newMovie = _fakers.Movie.Generate(); + + var requestBody = new + { + data = new + { + type = "movies", + attributes = new + { + title = newMovie.Title, + releaseYear = newMovie.ReleaseYear, + durationInSeconds = newMovie.DurationInSeconds + }, + relationships = new + { + genre = new + { + data = new + { + type = "genres", + id = "1" + } + }, + cast = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + } + } + } + }; + + const string route = "/movies"; + + Action setRequestHeaders = headers => headers.Add(ScopeHeaderName, "read:movies read:genres read:actors"); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, setRequestHeaders: setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resource_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.Generate().Title; + + var requestBody = new + { + data = new + { + type = "movies", + id = "1", + attributes = new + { + title = newTitle + } + } + }; + + const string route = "/movies/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resource_with_relationships_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.Generate().Title; + + var requestBody = new + { + data = new + { + type = "movies", + id = "1", + attributes = new + { + title = newTitle + }, + relationships = new + { + genre = new + { + data = new + { + type = "genres", + id = "1" + } + }, + cast = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + } + } + } + }; + + const string route = "/movies/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_delete_resource_without_scopes() + { + // Arrange + const string route = "/movies/1"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_ToOne_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + data = new + { + type = "genres", + id = "1" + } + }; + + const string route = "/movies/1/relationships/genre"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:genres write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + }; + + const string route = "/movies/1/relationships/cast"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + }; + + const string route = "/movies/1/relationships/cast"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_remove_from_ToMany_relationship_without_scopes() + { + // Arrange + var requestBody = new + { + data = new[] + { + new + { + type = "actors", + id = "1" + } + } + }; + + const string route = "/movies/1/relationships/cast"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + error.Title.Should().Be("Insufficient permissions to perform this request."); + error.Detail.Should().Be("Performing this request requires the following scopes: write:actors write:movies."); + error.Source.ShouldNotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs new file mode 100644 index 0000000000..a788b3e115 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs @@ -0,0 +1,104 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +// Implements IActionFilter instead of IAuthorizationFilter because it needs to execute *after* parsing query string parameters. +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class ScopesAuthorizationFilter : IActionFilter +{ + public void OnActionExecuting(ActionExecutingContext context) + { + var request = context.HttpContext.RequestServices.GetRequiredService(); + var targetedFields = context.HttpContext.RequestServices.GetRequiredService(); + var constraintProviders = context.HttpContext.RequestServices.GetRequiredService>(); + + if (request.Kind == EndpointKind.AtomicOperations) + { + // Handled in operators controller, because it requires access to the individual operations. + return; + } + + AuthScopeSet requestedScopes = AuthScopeSet.GetRequestedScopes(context.HttpContext.Request.Headers); + AuthScopeSet requiredScopes = GetRequiredScopes(request, targetedFields, constraintProviders); + + if (!requestedScopes.ContainsAll(requiredScopes)) + { + context.Result = new UnauthorizedObjectResult(new ErrorObject(HttpStatusCode.Unauthorized) + { + Title = "Insufficient permissions to perform this request.", + Detail = $"Performing this request requires the following scopes: {requiredScopes}.", + Source = new ErrorSource + { + Header = AuthScopeSet.ScopesHeaderName + } + }); + } + } + + public void OnActionExecuted(ActionExecutedContext context) + { + } + + private AuthScopeSet GetRequiredScopes(IJsonApiRequest request, ITargetedFields targetedFields, IEnumerable constraintProviders) + { + var requiredScopes = new AuthScopeSet(); + requiredScopes.IncludeFrom(request, targetedFields); + + var walker = new QueryStringWalker(requiredScopes); + walker.IncludeScopesFrom(constraintProviders); + + return requiredScopes; + } + + private sealed class QueryStringWalker : QueryExpressionRewriter + { + private readonly AuthScopeSet _authScopeSet; + + public QueryStringWalker(AuthScopeSet authScopeSet) + { + _authScopeSet = authScopeSet; + } + + public void IncludeScopesFrom(IEnumerable constraintProviders) + { + foreach (ExpressionInScope constraint in constraintProviders.SelectMany(provider => provider.GetConstraints())) + { + Visit(constraint.Expression, null); + } + } + + public override QueryExpression VisitIncludeElement(IncludeElementExpression expression, object? argument) + { + _authScopeSet.Include(expression.Relationship, Permission.Read); + + return base.VisitIncludeElement(expression, argument); + } + + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + { + foreach (ResourceFieldAttribute field in expression.Fields) + { + if (field is RelationshipAttribute relationship) + { + _authScopeSet.Include(relationship, Permission.Read); + } + else + { + _authScopeSet.Include(field.Type, Permission.Read); + } + } + + return base.VisitResourceFieldChain(expression, argument); + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesDbContext.cs new file mode 100644 index 0000000000..26ce29ff8f --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesDbContext.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ScopesDbContext : TestableDbContext +{ + public DbSet Movies => Set(); + public DbSet Actors => Set(); + public DbSet Genres => Set(); + + public ScopesDbContext(DbContextOptions options) + : base(options) + { + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesFakers.cs new file mode 100644 index 0000000000..3e89966d4d --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesFakers.cs @@ -0,0 +1,29 @@ +using Bogus; +using TestBuildingBlocks; + +// @formatter:wrap_chained_method_calls chop_if_long +// @formatter:wrap_before_first_method_call true + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +internal sealed class ScopesFakers : FakerContainer +{ + private readonly Lazy> _lazyMovieFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(movie => movie.Title, faker => faker.Random.Words()) + .RuleFor(movie => movie.ReleaseYear, faker => faker.Random.Int(1900, 2050)) + .RuleFor(movie => movie.DurationInSeconds, faker => faker.Random.Int(300, 14400))); + + private readonly Lazy> _lazyActorFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(actor => actor.Name, faker => faker.Person.FullName) + .RuleFor(actor => actor.BornAt, faker => faker.Date.Past())); + + private readonly Lazy> _lazyGenreFaker = new(() => new Faker() + .UseSeed(GetFakerSeed()) + .RuleFor(genre => genre.Name, faker => faker.Random.Word())); + + public Faker Movie => _lazyMovieFaker.Value; + public Faker Actor => _lazyActorFaker.Value; + public Faker Genre => _lazyGenreFaker.Value; +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesStartup.cs new file mode 100644 index 0000000000..0f6eaf4391 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesStartup.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class ScopesStartup : TestableStartup + where TDbContext : TestableDbContext +{ + public override void ConfigureServices(IServiceCollection services) + { + IMvcCoreBuilder mvcBuilder = services.AddMvcCore(options => options.Filters.Add(int.MaxValue)); + + services.AddJsonApi(SetJsonApiOptions, mvcBuilder: mvcBuilder); + } +} diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs index 71cd56fc6b..b30c4c6158 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Queries/TestableQueryExpressionRewriter.cs @@ -18,7 +18,7 @@ public override QueryExpression DefaultVisit(QueryExpression expression, object? return base.VisitComparison(expression, argument); } - public override QueryExpression? VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) { Capture(expression); return base.VisitResourceFieldChain(expression, argument);