You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
SECURITY: Remove conflicting record when IAccessControlProvider says you can't see it #445
Update the TableController.Create.cs file such that when a conflict happens and an IAccessControlProvider is set, we check the IAccessControlProvider to determine if the record is "in view" - if it is, then return it; if not, then return a Bad Request instead (no conflict).
Analysis: Hide Conflicting Record from Unauthorized Clients
Summary
When Repository.CreateAsync detects a duplicate ID, it throws HttpException(409) { Payload = existingEntity }. This propagates through DatasyncControllerAttribute.OnException which serializes the payload as the 409 response body. No EntityIsInView check is performed — the conflicting entity is always returned, even if the client's IAccessControlProvider data view would hide it. This is a data leak.
The fix: wrap Repository.CreateAsync in a try/catch for HttpException where StatusCode == 409, then check AccessControlProvider.EntityIsInView() on the payload entity. If in view, re-throw (existing behavior). If not in view, throw HttpException(400) without the payload.
T-Shirt Size: M
2 source files to change, 1 test helper to update
~4 new test methods (unit + integration)
Estimated effort: 2–4 hours
Breaking Change Assessment
Conditionally breaking — but safe for existing clients.
This behavior change ONLY occurs when ALL of these conditions are true simultaneously:
The server has an IAccessControlProvider configured with a non-null GetDataView()
The data view EXCLUDES the conflicting entity
A client attempts to create an entity whose ID collides with the hidden entity
For users NOT using IAccessControlProvider (or using the default AccessControlProvider which returns null from GetDataView()), EntityIsInView always returns true — so the existing 409-with-payload behavior is 100% preserved.
Client-side compatibility (confirmed via code review)
Online path (DatasyncServiceClient.AddAsync):
result.ThrowIfConflict(entity) — checks IsConflictStatusCode which is StatusCode is 409 or 412. A 400 does NOT match, so this is skipped.
result.ThrowIfNotSuccessful(requireContent: true) — 400 is not successful, so this throws DatasyncHttpException.
Result: Old client code that catches ConflictException<TEntity> will NOT catch the 400. Instead, DatasyncHttpException is thrown. Since ConflictException<TEntity> extends DatasyncHttpException, any code that catches the base type still works.
response.IsConflictStatusCode is false for 400, so conflict resolution is skipped entirely.
!isSuccessful is true, so the operation is marked OperationState.Failed with HttpStatusCode = 400 and added to PushResult.FailedRequests.
Result: The push operation fails cleanly. No conflict resolver is invoked (which is correct — the client can't see the conflicting entity, so conflict resolution is meaningless).
Bottom line: Existing client code handles a 400 gracefully. The behavior change is scoped to the narrow intersection of users who both configure an IAccessControlProvider with a data view AND have ID collisions against hidden entities — and for those users, the old behavior was a data leak.
Implementation
The change to TableController.Create.cs follows the exact EntityIsInView pattern used in Delete (TableController.Delete.cs:28-32), Read (TableController.Read.cs:29-33), and Replace (TableController.Replace.cs:38-42):
// In CreateAsync, wrap the Repository.CreateAsync call:try{awaitRepository.CreateAsync(entity,cancellationToken).ConfigureAwait(false);}catch(HttpExceptionex)when(ex.StatusCode==StatusCodes.Status409Conflict&&ex.PayloadisTEntityconflictEntity){if(!AccessControlProvider.EntityIsInView(conflictEntity)){Logger.LogWarning("CreateAsync: {id} statusCode=400 conflict not in view",entity.Id);thrownewHttpException(StatusCodes.Status400BadRequest);}throw;// re-throw original 409 with payload}
Files to Change
File
Change
TableController.Create.cs
Add try/catch around Repository.CreateAsync with EntityIsInView check
BaseTest.cs
Modify FakeRepository to support conflict with payload (currently new HttpException(409) has no Payload)
Testing Impact
The existing FakeRepository at BaseTest.cs:41 throws new HttpException(409) WITHOUT a payload. The real repositories (InMemory, EF Core, LiteDb, MongoDB) throw with { Payload = existingEntity }. The test helper must be updated to optionally include a payload.
Existing Test
Action
CreateAsync_RepositoryException_Throws (line 30)
No change — uses throwConflict = true with no payload, catch only matches Payload is TEntity so this path is unaffected
Create_ExistingId_Returns409 (integration, line 41)
No change — uses default AccessControlProvider (no data view)
Create_SoftDeleted_Returns409 (integration, line 56)
No change — uses default AccessControlProvider
New unit tests needed:
New Test
Purpose
CreateAsync_Conflict_EntityInView_Returns409
ACP with data view that INCLUDES the conflicting entity → 409 with payload (existing behavior preserved)
CreateAsync_Conflict_EntityNotInView_Returns400
ACP with data view that EXCLUDES the conflicting entity → 400 with no payload
New integration tests needed:
New Test
Purpose
Create_ExistingId_WithACP_InView_Returns409
Full pipeline: ACP allows view → 409
Create_ExistingId_WithACP_NotInView_Returns400
Full pipeline: ACP hides entity → 400
The test helper FakeRepository needs a new overload or parameter to include a payload entity on conflict:
// BaseTest.cs FakeRepository, when throwConflict == true and conflictEntity is provided:mock.CreateAsync(...).Returns(ValueTask.FromException(newHttpException(409){Payload=conflictEntity}));
Activity
ahallctr-jpg commentedon Mar 15, 2026
Analysis: Hide Conflicting Record from Unauthorized Clients
Summary
When
Repository.CreateAsyncdetects a duplicate ID, it throwsHttpException(409) { Payload = existingEntity }. This propagates throughDatasyncControllerAttribute.OnExceptionwhich serializes the payload as the 409 response body. NoEntityIsInViewcheck is performed — the conflicting entity is always returned, even if the client'sIAccessControlProviderdata view would hide it. This is a data leak.The fix: wrap
Repository.CreateAsyncin a try/catch forHttpExceptionwhereStatusCode == 409, then checkAccessControlProvider.EntityIsInView()on the payload entity. If in view, re-throw (existing behavior). If not in view, throwHttpException(400)without the payload.T-Shirt Size: M
Breaking Change Assessment
Conditionally breaking — but safe for existing clients.
This behavior change ONLY occurs when ALL of these conditions are true simultaneously:
IAccessControlProviderconfigured with a non-nullGetDataView()For users NOT using
IAccessControlProvider(or using the defaultAccessControlProviderwhich returnsnullfromGetDataView()),EntityIsInViewalways returnstrue— so the existing 409-with-payload behavior is 100% preserved.Client-side compatibility (confirmed via code review)
Online path (
DatasyncServiceClient.AddAsync):result.ThrowIfConflict(entity)— checksIsConflictStatusCodewhich isStatusCode is 409 or 412. A 400 does NOT match, so this is skipped.result.ThrowIfNotSuccessful(requireContent: true)— 400 is not successful, so this throwsDatasyncHttpException.ConflictException<TEntity>will NOT catch the 400. Instead,DatasyncHttpExceptionis thrown. SinceConflictException<TEntity>extendsDatasyncHttpException, any code that catches the base type still works.Offline push path (
OperationsQueueManager.PushOperationAsync):response.IsConflictStatusCodeisfalsefor 400, so conflict resolution is skipped entirely.!isSuccessfulistrue, so the operation is markedOperationState.FailedwithHttpStatusCode = 400and added toPushResult.FailedRequests.Bottom line: Existing client code handles a 400 gracefully. The behavior change is scoped to the narrow intersection of users who both configure an
IAccessControlProviderwith a data view AND have ID collisions against hidden entities — and for those users, the old behavior was a data leak.Implementation
The change to
TableController.Create.csfollows the exactEntityIsInViewpattern used in Delete (TableController.Delete.cs:28-32), Read (TableController.Read.cs:29-33), and Replace (TableController.Replace.cs:38-42):Files to Change
TableController.Create.csRepository.CreateAsyncwithEntityIsInViewcheckBaseTest.csFakeRepositoryto support conflict with payload (currentlynew HttpException(409)has noPayload)Testing Impact
The existing
FakeRepositoryatBaseTest.cs:41throwsnew HttpException(409)WITHOUT a payload. The real repositories (InMemory, EF Core, LiteDb, MongoDB) throw with{ Payload = existingEntity }. The test helper must be updated to optionally include a payload.CreateAsync_RepositoryException_Throws(line 30)throwConflict = truewith no payload, catch only matchesPayload is TEntityso this path is unaffectedCreate_ExistingId_Returns409(integration, line 41)AccessControlProvider(no data view)Create_SoftDeleted_Returns409(integration, line 56)AccessControlProviderNew unit tests needed:
CreateAsync_Conflict_EntityInView_Returns409CreateAsync_Conflict_EntityNotInView_Returns400New integration tests needed:
Create_ExistingId_WithACP_InView_Returns409Create_ExistingId_WithACP_NotInView_Returns400The test helper
FakeRepositoryneeds a new overload or parameter to include a payload entity on conflict:fix: hide conflicting record on create when not in IAccessControlProv…