Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5ced929
test(capture): reproduce Viewer board-attachment authorization gap
Chris0Jeky Sep 20, 2026
770b487
test(capture): add authenticated Viewer attachment negative
Chris0Jeky Sep 20, 2026
ff77d8a
fix(capture): require write access for board attachment
Chris0Jeky Sep 20, 2026
553ad71
test(capture): import shared error codes
Chris0Jeky Sep 20, 2026
cac5fe0
test(capture): align dual-write fixture with write authorization
Chris0Jeky Sep 20, 2026
2026199
test(capture): keep authorization usernames within contract
Chris0Jeky Sep 20, 2026
3311e01
test(capture): align fixtures with write authorization
Chris0Jeky Sep 21, 2026
a38a890
fix(capture): serialize board authorization with enqueue
Chris0Jeky Sep 21, 2026
86eca5d
fix(capture): hide board entry points for viewers
Chris0Jeky Sep 21, 2026
e336ccb
fix(capture): hide column capture for viewers
Chris0Jeky Sep 21, 2026
0a77af8
fix(auth): refresh tracked board access before capture
Chris0Jeky Sep 21, 2026
34fb5c8
fix: protect queued capture board writes
Chris0Jeky Sep 21, 2026
f642869
test(frontend): align BoardView fixture with write capability
Chris0Jeky Sep 21, 2026
aec449b
test(api): reload board access before mutation
Chris0Jeky Sep 21, 2026
f06012f
fix(inbox): gate scoped capture by board write access
Chris0Jeky Sep 21, 2026
2dac12e
fix(queue): normalize capture request types before authorization
Chris0Jeky Sep 22, 2026
7704fb1
Fix scoped Inbox capture escape hatch
Chris0Jeky Sep 22, 2026
3c37b19
Preserve direct nib capture callers
Chris0Jeky Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Data;

namespace Taskdeck.Application.Interfaces;

public interface IUnitOfWork
Expand Down Expand Up @@ -58,6 +60,17 @@ public interface IUnitOfWork
Task BeginReadTransactionAsync(CancellationToken cancellationToken = default);

Task BeginTransactionAsync(CancellationToken cancellationToken = default);

/// <summary>
/// Begins a transaction at an explicit isolation level. The default implementation keeps
/// lightweight test doubles source-compatible; the production unit of work overrides it so
/// authorization reads that guard a write can share a serializable snapshot.
/// </summary>
Task BeginTransactionAsync(
IsolationLevel isolationLevel,
CancellationToken cancellationToken = default)
=> BeginTransactionAsync(cancellationToken);

Task CommitTransactionAsync(CancellationToken cancellationToken = default);
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}
50 changes: 48 additions & 2 deletions backend/src/Taskdeck.Application/Services/CaptureService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Data;
using Microsoft.Extensions.Logging;
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
Expand Down Expand Up @@ -183,6 +184,17 @@ public async Task<Result<CaptureItemDto>> CreateAsync(
if (userId == Guid.Empty)
return Result.Failure<CaptureItemDto>(ErrorCodes.ValidationError, "UserId cannot be empty");

var boardTransactionStarted = false;

async Task RollbackBoardTransactionAsync()
{
if (!boardTransactionStarted)
return;

await _unitOfWork.RollbackTransactionAsync(cancellationToken);
boardTransactionStarted = false;
}

try
{
var user = await _unitOfWork.Users.GetByIdAsync(userId, cancellationToken);
Expand All @@ -191,17 +203,39 @@ public async Task<Result<CaptureItemDto>> CreateAsync(

if (dto.BoardId.HasValue)
{
var permissionResult = await _authorizationService.CanReadBoardAsync(userId, dto.BoardId.Value);
// The authorization read and queue insert must share one serializable snapshot.
// Otherwise a board owner can demote this caller after CanWriteBoardAsync returns
// but before SaveChangesAsync, admitting a capture under stale write authority.
await _unitOfWork.BeginTransactionAsync(
IsolationLevel.Serializable,
cancellationToken);
boardTransactionStarted = true;

// A board-scoped capture can enter that board's proposal queue. Keep the
// attachment boundary aligned with triage: readable Viewer access is not
// authority to inject work into a board only writers can modify (#3291).
var permissionResult = await _authorizationService.CanWriteBoardAsync(userId, dto.BoardId.Value);
Comment thread
Chris0Jeky marked this conversation as resolved.
Comment thread
Chris0Jeky marked this conversation as resolved.
Comment thread
Chris0Jeky marked this conversation as resolved.
Comment thread
Chris0Jeky marked this conversation as resolved.
if (!permissionResult.IsSuccess)
{
await RollbackBoardTransactionAsync();
return Result.Failure<CaptureItemDto>(permissionResult.ErrorCode, permissionResult.ErrorMessage);
}

if (!permissionResult.Value)
return Result.Failure<CaptureItemDto>(ErrorCodes.Forbidden, "You do not have access to this board");
{
await RollbackBoardTransactionAsync();
return Result.Failure<CaptureItemDto>(
ErrorCodes.Forbidden,
"You do not have permission to attach captures to this board");
}
}

var sourceResult = ResolveSource(dto.Source);
if (!sourceResult.IsSuccess)
{
await RollbackBoardTransactionAsync();
return Result.Failure<CaptureItemDto>(sourceResult.ErrorCode, sourceResult.ErrorMessage);
}

var payload = new CapturePayloadV1(
CaptureRequestContract.CurrentSchemaVersion,
Expand Down Expand Up @@ -243,6 +277,12 @@ public async Task<Result<CaptureItemDto>> CreateAsync(

await _unitOfWork.SaveChangesAsync(cancellationToken);

if (boardTransactionStarted)
{
await _unitOfWork.CommitTransactionAsync(cancellationToken);
boardTransactionStarted = false;
}

return Result.Success(MapToDetailDto(
request,
attributedPayload,
Expand All @@ -251,8 +291,14 @@ public async Task<Result<CaptureItemDto>> CreateAsync(
}
catch (DomainException ex)
{
await RollbackBoardTransactionAsync();
return Result.Failure<CaptureItemDto>(ex.ErrorCode, ex.Message);
}
catch
{
await RollbackBoardTransactionAsync();
throw;
}
}

public async Task<Result<IReadOnlyList<CaptureItemSummaryDto>>> ListAsync(
Expand Down
74 changes: 59 additions & 15 deletions backend/src/Taskdeck.Application/Services/LlmQueueService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Data;
using Taskdeck.Application.DTOs;
using Taskdeck.Application.Interfaces;
using Taskdeck.Domain.Common;
Expand Down Expand Up @@ -43,33 +44,33 @@ public LlmQueueService(

public async Task<Result<LlmRequestDto>> AddToQueueAsync(Guid userId, CreateLlmRequestDto dto)
{
var boardTransactionStarted = false;

async Task RollbackBoardTransactionAsync()
{
if (!boardTransactionStarted)
return;

await _unitOfWork.RollbackTransactionAsync();
boardTransactionStarted = false;
}

try
{
var user = await _unitOfWork.Users.GetByIdAsync(userId);
if (user == null)
return Result.Failure<LlmRequestDto>(ErrorCodes.NotFound, $"User with ID {userId} not found");

if (dto.BoardId.HasValue)
{
var permissionResult = await _authorizationService.CanReadBoardAsync(userId, dto.BoardId.Value);
if (!permissionResult.IsSuccess)
{
return Result.Failure<LlmRequestDto>(permissionResult.ErrorCode, permissionResult.ErrorMessage);
}

if (!permissionResult.Value)
{
return Result.Failure<LlmRequestDto>(ErrorCodes.Forbidden, "You do not have access to this board");
}
}

var requestTypeValidation = CaptureRequestContract.ValidateRequestType(dto.RequestType);
if (!requestTypeValidation.IsSuccess)
{
return Result.Failure<LlmRequestDto>(requestTypeValidation.ErrorCode, requestTypeValidation.ErrorMessage);
}

var requestType = dto.RequestType;
// Validation accepts surrounding whitespace, so carry the same normalized value
// into capture classification. Otherwise a padded capture type could select the
// readable-board gate instead of the write gate below.
var requestType = dto.RequestType.Trim();
var payload = dto.Payload;
CapturePayloadV1? capturePayload = null;
if (CaptureRequestContract.IsCaptureRequestType(requestType))
Expand All @@ -88,6 +89,37 @@ public async Task<Result<LlmRequestDto>> AddToQueueAsync(Guid userId, CreateLlmR
payload = CaptureRequestContract.SerializePayload(capturePayload);
}

if (dto.BoardId.HasValue)
{
// Capture-shaped queue requests are an alternate capture intake path, not merely
// readable board metadata. Keep their write gate and transaction boundary aligned
// with CaptureService so a Viewer cannot attach new work through this endpoint.
if (capturePayload is not null)
{
await _unitOfWork.BeginTransactionAsync(
IsolationLevel.Serializable);
boardTransactionStarted = true;
}

var permissionResult = capturePayload is not null
? await _authorizationService.CanWriteBoardAsync(userId, dto.BoardId.Value)
: await _authorizationService.CanReadBoardAsync(userId, dto.BoardId.Value);
Comment thread
Chris0Jeky marked this conversation as resolved.
if (!permissionResult.IsSuccess)
{
await RollbackBoardTransactionAsync();
return Result.Failure<LlmRequestDto>(permissionResult.ErrorCode, permissionResult.ErrorMessage);
}

if (!permissionResult.Value)
{
await RollbackBoardTransactionAsync();
var message = capturePayload is not null
? "You do not have permission to attach captures to this board"
: "You do not have access to this board";
return Result.Failure<LlmRequestDto>(ErrorCodes.Forbidden, message);
}
}

var request = new LlmRequest(userId, requestType, payload, dto.BoardId);
await _unitOfWork.LlmQueue.AddAsync(request);

Expand All @@ -102,12 +134,24 @@ public async Task<Result<LlmRequestDto>> AddToQueueAsync(Guid userId, CreateLlmR

await _unitOfWork.SaveChangesAsync();

if (boardTransactionStarted)
{
await _unitOfWork.CommitTransactionAsync();
boardTransactionStarted = false;
}

return Result.Success(MapToDto(request));
}
catch (DomainException ex)
{
await RollbackBoardTransactionAsync();
return Result.Failure<LlmRequestDto>(ex.ErrorCode, ex.Message);
}
catch
{
await RollbackBoardTransactionAsync();
throw;
}
}

public async Task<Result<IEnumerable<LlmRequestDto>>> GetUserQueueAsync(Guid userId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public BoardAccessRepository(TaskdeckDbContext context) : base(context)
public async Task<BoardAccess?> GetByBoardAndUserAsync(Guid boardId, Guid userId, CancellationToken cancellationToken = default)
{
return await _context.BoardAccesses
.AsNoTracking()
.Include(ba => ba.User)
.Include(ba => ba.Board)
.FirstOrDefaultAsync(ba => ba.BoardId == boardId && ba.UserId == userId, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,15 @@ public async Task BeginTransactionAsync(CancellationToken cancellationToken = de
_transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
}

public async Task BeginTransactionAsync(
IsolationLevel isolationLevel,
CancellationToken cancellationToken = default)
{
_transaction = await _context.Database.BeginTransactionAsync(
isolationLevel,
cancellationToken);
}

public async Task CommitTransactionAsync(CancellationToken cancellationToken = default)
{
var transaction = _transaction;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Taskdeck.Domain.Entities;
using Taskdeck.Domain.Enums;
using Taskdeck.Infrastructure.Persistence;
using Taskdeck.Infrastructure.Repositories;
using Xunit;

namespace Taskdeck.Api.Tests;

public sealed class BoardAccessRepositoryFreshnessTests
{
[Fact]
public async Task GetByBoardAndUserAsync_reads_role_changes_after_an_earlier_read()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-board-access-freshness-{Guid.NewGuid():N}.db");
try
{
var options = new DbContextOptionsBuilder<TaskdeckDbContext>()
.UseSqlite(TestSqlite.ConnectionString(dbPath))
.Options;

var owner = new User("access-freshness-owner", $"access-freshness-owner-{Guid.NewGuid():N}@example.com", "hash");
var member = new User("access-freshness-member", $"access-freshness-member-{Guid.NewGuid():N}@example.com", "hash");
var board = new Board("Access freshness", ownerId: owner.Id);
var access = new BoardAccess(board.Id, member.Id, UserRole.Editor, owner.Id);

await using (var seed = new TaskdeckDbContext(options))
{
await seed.Database.MigrateAsync();
seed.Users.AddRange(owner, member);
seed.Boards.Add(board);
seed.BoardAccesses.Add(access);
await seed.SaveChangesAsync();
}

await using var reader = new TaskdeckDbContext(options);
var repository = new BoardAccessRepository(reader);
var initial = await repository.GetByBoardAndUserAsync(board.Id, member.Id);
initial!.Role.Should().Be(UserRole.Editor);

await using (var writer = new TaskdeckDbContext(options))
{
var persisted = await writer.BoardAccesses.SingleAsync(value =>
value.BoardId == board.Id && value.UserId == member.Id);
persisted.UpdateRole(UserRole.Viewer, owner.Id);
await writer.SaveChangesAsync();
}

var refreshed = await repository.GetByBoardAndUserAsync(board.Id, member.Id);
refreshed!.Role.Should().Be(UserRole.Viewer);
}
finally
{
foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" })
{
try { File.Delete(dbPath + suffix); }
catch (IOException) { }
}
}
}
}
13 changes: 10 additions & 3 deletions backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -888,8 +888,8 @@ public async Task Triage_ShouldSucceed_WhenTargetBoardMemberIsEditor()
[Fact]
public async Task Triage_ShouldReturnForbidden_WhenAlreadyLinkedBoardIsReadOnlyForCaller()
{
// The gate has to sit on the effective board, not only on the triage body: a capture created
// with a readable board (create is read-gated) and accepted with no body is the same vector.
// Create while the caller is write-capable, then demote the membership before triage. The
// already-linked board gate must still reject the read-only caller with no target body.
var ownerClient = _factory.CreateClient();
var viewerClient = _factory.CreateClient();
await ApiTestHarness.AuthenticateAsync(ownerClient, "capture-triage-linked-gate-owner");
Expand All @@ -898,8 +898,10 @@ public async Task Triage_ShouldReturnForbidden_WhenAlreadyLinkedBoardIsReadOnlyF

var grantResponse = await ownerClient.PostAsJsonAsync(
$"/api/boards/{board.Id}/access",
new GrantAccessDto(board.Id, viewer.UserId, UserRole.Viewer));
new GrantAccessDto(board.Id, viewer.UserId, UserRole.Editor));
grantResponse.StatusCode.Should().Be(HttpStatusCode.OK);
var access = await grantResponse.Content.ReadFromJsonAsync<BoardAccessDto>();
access.Should().NotBeNull();

var createResponse = await viewerClient.PostAsJsonAsync(
"/api/capture/items",
Expand All @@ -909,6 +911,11 @@ public async Task Triage_ShouldReturnForbidden_WhenAlreadyLinkedBoardIsReadOnlyF
created.Should().NotBeNull();
created!.BoardId.Should().Be(board.Id);

var demoteResponse = await ownerClient.PutAsJsonAsync(
$"/api/boards/{board.Id}/access/{access!.Id}",
new UpdateAccessDto(UserRole.Viewer));
demoteResponse.StatusCode.Should().Be(HttpStatusCode.OK);

var triageResponse = await viewerClient.PostAsync($"/api/capture/items/{created.Id}/triage", null);

await ApiTestHarness.AssertErrorContractAsync(triageResponse, HttpStatusCode.Forbidden, "Forbidden");
Expand Down
Loading
Loading