Skip to content

Commit 6ce7522

Browse files
authored
Block GET request on secondary/relationship endpoint when the relationship doesn't contain the AllowView capability (#1992)
1 parent 770f758 commit 6ce7522

11 files changed

Lines changed: 167 additions & 20 deletions

File tree

docs/usage/resources/relationships.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ Indicates whether the relationship can be returned in responses. When not allowe
289289
Otherwise, the relationship (and its related resources, when included) are silently omitted.
290290

291291
> [!WARNING]
292-
> This setting does not affect retrieving the related resources directly.
292+
> This setting affects the secondary and relationship endpoints, but it does not affect retrieving the related resources directly.
293293
294294
```c#
295295
#nullable enable

src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyCapabilities.shared.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public enum HasManyCapabilities
1616
/// disabled will return an HTTP 400 response. Otherwise, the relationship (and its related resources, when included) are silently omitted.
1717
/// </summary>
1818
/// <remarks>
19-
/// Note this setting does not affect retrieving the related resources directly.
19+
/// Note this setting affects the secondary and relationship endpoints, but it does not affect retrieving the related resources directly.
2020
/// </remarks>
2121
AllowView = 1,
2222

src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneCapabilities.shared.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public enum HasOneCapabilities
1616
/// disabled will return an HTTP 400 response. Otherwise, the relationship (and its related resources, when included) are silently omitted.
1717
/// </summary>
1818
/// <remarks>
19-
/// Note this setting does not affect retrieving the related resources directly.
19+
/// Note this setting affects the secondary and relationship endpoints, but it does not affect retrieving the related resources directly.
2020
/// </remarks>
2121
AllowView = 1,
2222

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System.Net;
2+
using JetBrains.Annotations;
3+
using JsonApiDotNetCore.Resources.Annotations;
4+
using JsonApiDotNetCore.Serialization.Objects;
5+
6+
namespace JsonApiDotNetCore.Errors;
7+
8+
[PublicAPI]
9+
public sealed class BlockedGetRelationshipException(RelationshipAttribute relationship)
10+
: JsonApiException(new ErrorObject(HttpStatusCode.Forbidden)
11+
{
12+
Title = "The requested endpoint is not accessible.",
13+
Detail = $"Retrieving the relationship '{relationship.PublicName}' of type '{relationship.LeftType}' is not allowed."
14+
});

src/JsonApiDotNetCore/Services/JsonApiResourceService.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ public virtual async Task<TResource> GetAsync([DisallowNull] TId id, Cancellatio
115115
AssertHasRelationship(_request.Relationship, relationshipName);
116116
AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType);
117117
AssertSecondaryResourceTypeInJsonApiRequestIsNotNull(_request.SecondaryResourceType);
118+
AssertCanViewRelationship(_request.Relationship);
118119

119120
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)");
120121

@@ -167,6 +168,7 @@ public virtual async Task<TResource> GetAsync([DisallowNull] TId id, Cancellatio
167168
AssertHasRelationship(_request.Relationship, relationshipName);
168169
AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType);
169170
AssertSecondaryResourceTypeInJsonApiRequestIsNotNull(_request.SecondaryResourceType);
171+
AssertCanViewRelationship(_request.Relationship);
170172

171173
using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship");
172174

@@ -696,6 +698,22 @@ private void AssertHasRelationship([SysNotNull] RelationshipAttribute? relations
696698
}
697699
}
698700

701+
[AssertionMethod]
702+
private void AssertCanViewRelationship(RelationshipAttribute relationship)
703+
{
704+
bool allowView = relationship switch
705+
{
706+
HasOneAttribute hasOneRelationship when !hasOneRelationship.Capabilities.HasFlag(HasOneCapabilities.AllowView) => false,
707+
HasManyAttribute hasManyRelationship when !hasManyRelationship.Capabilities.HasFlag(HasManyCapabilities.AllowView) => false,
708+
_ => true
709+
};
710+
711+
if (!allowView)
712+
{
713+
throw new BlockedGetRelationshipException(relationship);
714+
}
715+
}
716+
699717
[AssertionMethod]
700718
private void AssertPrimaryResourceTypeInJsonApiRequestIsNotNull([SysNotNull] ResourceType? resourceType)
701719
{

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public TimeOffsetTests(IntegrationTestContext<TestableStartup<QueryStringDbConte
2121
_testContext = testContext;
2222

2323
testContext.UseController<CalendarsController>();
24+
testContext.UseController<AppointmentsController>();
2425
testContext.UseController<RemindersController>();
2526

2627
testContext.ConfigureServices(services =>
@@ -202,22 +203,22 @@ public async Task Can_filter_comparison_on_relative_time_in_nested_expression()
202203
var timeProvider = _testContext.Factory.Services.GetRequiredService<TimeProvider>();
203204
DateTimeOffset utcNow = timeProvider.GetUtcNow();
204205

205-
Calendar calendar = _fakers.Calendar.GenerateOne();
206-
calendar.Appointments = _fakers.Appointment.GenerateSet(2);
206+
List<Appointment> appointments = _fakers.Appointment.GenerateList(2);
207207

208-
calendar.Appointments.ElementAt(0).Reminders = _fakers.Reminder.GenerateList(1);
209-
calendar.Appointments.ElementAt(0).Reminders[0].RemindsAt = utcNow.UtcDateTime;
208+
appointments[0].Reminders = _fakers.Reminder.GenerateList(1);
209+
appointments[0].Reminders[0].RemindsAt = utcNow.UtcDateTime;
210210

211-
calendar.Appointments.ElementAt(1).Reminders = _fakers.Reminder.GenerateList(1);
212-
calendar.Appointments.ElementAt(1).Reminders[0].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(30)).UtcDateTime;
211+
appointments[1].Reminders = _fakers.Reminder.GenerateList(1);
212+
appointments[1].Reminders[0].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(30)).UtcDateTime;
213213

214214
await _testContext.RunOnDatabaseAsync(async dbContext =>
215215
{
216-
dbContext.Calendars.Add(calendar);
216+
await dbContext.ClearTableAsync<Appointment>();
217+
dbContext.Appointments.AddRange(appointments);
217218
await dbContext.SaveChangesAsync();
218219
});
219220

220-
string route = $"/calendars/{calendar.StringId}/appointments?filter=has(reminders,equals(remindsAt,timeOffset('%2B0:30:00')))";
221+
const string route = "/appointments?filter=has(reminders,equals(remindsAt,timeOffset('%2B0:30:00')))";
221222

222223
// Act
223224
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
@@ -227,6 +228,6 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
227228

228229
responseDocument.Data.ManyValue.Should().HaveCount(1);
229230

230-
responseDocument.Data.ManyValue[0].Id.Should().Be(calendar.Appointments.ElementAt(1).StringId);
231+
responseDocument.Data.ManyValue[0].Id.Should().Be(appointments[1].StringId);
231232
}
232233
}

test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/DisablePaginationOnRelationshipTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,17 +149,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
149149
public async Task Ignores_pagination_from_query_string()
150150
{
151151
// Arrange
152-
Calendar calendar = _fakers.Calendar.GenerateOne();
153-
calendar.Appointments = _fakers.Appointment.GenerateSet(3);
154-
calendar.Appointments.ElementAt(0).Reminders = _fakers.Reminder.GenerateList(7);
152+
List<Appointment> appointments = _fakers.Appointment.GenerateList(3);
153+
appointments[0].Reminders = _fakers.Reminder.GenerateList(7);
155154

156155
await _testContext.RunOnDatabaseAsync(async dbContext =>
157156
{
158-
dbContext.Calendars.Add(calendar);
157+
await dbContext.ClearTableAsync<Appointment>();
158+
dbContext.Appointments.AddRange(appointments);
159159
await dbContext.SaveChangesAsync();
160160
});
161161

162-
string route = $"calendars/{calendar.StringId}/appointments?include=reminders&page[size]=2,reminders:4";
162+
const string route = "appointments?include=reminders&page[size]=2,reminders:4";
163163

164164
// Act
165165
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
@@ -170,7 +170,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
170170
responseDocument.Data.ManyValue.Should().HaveCount(2);
171171
responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("appointments"));
172172

173-
ResourceObject firstAppointment = responseDocument.Data.ManyValue.Single(resource => resource.Id == calendar.Appointments.ElementAt(0).StringId);
173+
ResourceObject firstAppointment = responseDocument.Data.ManyValue.Single(resource => resource.Id == appointments[0].StringId);
174174

175175
firstAppointment.Relationships.Should().ContainKey("reminders").WhoseValue.With(value =>
176176
{

test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchRelationshipTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public FetchRelationshipTests(IntegrationTestContext<TestableStartup<ReadWriteDb
1616
_testContext = testContext;
1717

1818
testContext.UseController<WorkItemsController>();
19+
testContext.UseController<WorkItemGroupsController>();
1920
testContext.UseController<UserAccountsController>();
2021
}
2122

@@ -68,6 +69,33 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
6869
responseDocument.Data.Value.Should().BeNull();
6970
}
7071

72+
[Fact]
73+
public async Task Cannot_get_ManyToOne_relationship_with_blocked_capability()
74+
{
75+
WorkItem workItem = _fakers.WorkItem.GenerateOne();
76+
77+
await _testContext.RunOnDatabaseAsync(async dbContext =>
78+
{
79+
dbContext.WorkItems.Add(workItem);
80+
await dbContext.SaveChangesAsync();
81+
});
82+
83+
string route = $"/workItems/{workItem.StringId}/relationships/group";
84+
85+
// Act
86+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
87+
88+
// Assert
89+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden);
90+
91+
responseDocument.Errors.Should().HaveCount(1);
92+
93+
ErrorObject error = responseDocument.Errors[0];
94+
error.StatusCode.Should().Be(HttpStatusCode.Forbidden);
95+
error.Title.Should().Be("The requested endpoint is not accessible.");
96+
error.Detail.Should().Be("Retrieving the relationship 'group' of type 'workItems' is not allowed.");
97+
}
98+
7199
[Fact]
72100
public async Task Can_get_OneToMany_relationship()
73101
{
@@ -125,6 +153,33 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
125153
responseDocument.Data.ManyValue.Should().BeEmpty();
126154
}
127155

156+
[Fact]
157+
public async Task Cannot_get_OneToMany_relationship_with_blocked_capability()
158+
{
159+
WorkItemGroup group = _fakers.WorkItemGroup.GenerateOne();
160+
161+
await _testContext.RunOnDatabaseAsync(async dbContext =>
162+
{
163+
dbContext.Groups.Add(group);
164+
await dbContext.SaveChangesAsync();
165+
});
166+
167+
string route = $"/workItemGroups/{group.StringId}/relationships/items";
168+
169+
// Act
170+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
171+
172+
// Assert
173+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden);
174+
175+
responseDocument.Errors.Should().HaveCount(1);
176+
177+
ErrorObject error = responseDocument.Errors[0];
178+
error.StatusCode.Should().Be(HttpStatusCode.Forbidden);
179+
error.Title.Should().Be("The requested endpoint is not accessible.");
180+
error.Detail.Should().Be("Retrieving the relationship 'items' of type 'workItemGroups' is not allowed.");
181+
}
182+
128183
[Fact]
129184
public async Task Can_get_ManyToMany_relationship()
130185
{

test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Fetching/FetchResourceTests.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public FetchResourceTests(IntegrationTestContext<TestableStartup<ReadWriteDbCont
1616
_testContext = testContext;
1717

1818
testContext.UseController<WorkItemsController>();
19+
testContext.UseController<WorkItemGroupsController>();
1920
testContext.UseController<UserAccountsController>();
2021
testContext.UseController<WorkTagsController>();
2122
}
@@ -191,6 +192,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
191192
responseDocument.Data.Value.Should().BeNull();
192193
}
193194

195+
[Fact]
196+
public async Task Cannot_get_secondary_ManyToOne_resource_with_blocked_capability()
197+
{
198+
// Arrange
199+
WorkItem workItem = _fakers.WorkItem.GenerateOne();
200+
201+
await _testContext.RunOnDatabaseAsync(async dbContext =>
202+
{
203+
dbContext.WorkItems.Add(workItem);
204+
await dbContext.SaveChangesAsync();
205+
});
206+
207+
string route = $"/workItems/{workItem.StringId}/group";
208+
209+
// Act
210+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
211+
212+
// Assert
213+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden);
214+
215+
responseDocument.Errors.Should().HaveCount(1);
216+
217+
ErrorObject error = responseDocument.Errors[0];
218+
error.StatusCode.Should().Be(HttpStatusCode.Forbidden);
219+
error.Title.Should().Be("The requested endpoint is not accessible.");
220+
error.Detail.Should().Be("Retrieving the relationship 'group' of type 'workItems' is not allowed.");
221+
}
222+
194223
[Fact]
195224
public async Task Can_get_secondary_OneToMany_resources()
196225
{
@@ -252,6 +281,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
252281
responseDocument.Data.ManyValue.Should().BeEmpty();
253282
}
254283

284+
[Fact]
285+
public async Task Cannot_get_secondary_OneToMany_resources_with_blocked_capability()
286+
{
287+
// Arrange
288+
WorkItemGroup group = _fakers.WorkItemGroup.GenerateOne();
289+
290+
await _testContext.RunOnDatabaseAsync(async dbContext =>
291+
{
292+
dbContext.Groups.Add(group);
293+
await dbContext.SaveChangesAsync();
294+
});
295+
296+
string route = $"/workItemGroups/{group.StringId}/items";
297+
298+
// Act
299+
(HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync<Document>(route);
300+
301+
// Assert
302+
httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden);
303+
304+
responseDocument.Errors.Should().HaveCount(1);
305+
306+
ErrorObject error = responseDocument.Errors[0];
307+
error.StatusCode.Should().Be(HttpStatusCode.Forbidden);
308+
error.Title.Should().Be("The requested endpoint is not accessible.");
309+
error.Detail.Should().Be("Retrieving the relationship 'items' of type 'workItemGroups' is not allowed.");
310+
}
311+
255312
[Fact]
256313
public async Task Can_get_secondary_ManyToMany_resources()
257314
{

test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/WorkItem.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,6 @@ public bool IsImportant
4747
[HasMany]
4848
public IList<WorkItem> RelatedTo { get; set; } = new List<WorkItem>();
4949

50-
[HasOne(Capabilities = HasOneCapabilities.All & ~HasOneCapabilities.AllowSet)]
50+
[HasOne(Capabilities = HasOneCapabilities.All & ~(HasOneCapabilities.AllowView | HasOneCapabilities.AllowSet))]
5151
public WorkItemGroup? Group { get; set; }
5252
}

0 commit comments

Comments
 (0)