Skip to main content

Journal Type-Specific Authorization Pattern

Problem Statement

Different journal types (General Journals, Customer Payment Journals, Vendor Payment Journals) need to expose the same critical operations (Post, Reverse, Delete) while applying journal-type-specific permissions.

Direct duplication of endpoints across controllers leads to:

  • Code maintenance overhead
  • Risk of inconsistent behavior
  • Violation of DRY principle

Solution: Base Controller with Inherited Authorization

Architecture Overview

Implementation Pattern

1. Base Controller (Shared Logic)

// src/modules/GeneralLedger/GeneralLedger.API/Application/Features/Shared/BaseLedgerJournalController.cs

/// <summary>
/// Base controller providing common journal operations.
/// Derived controllers apply journal-type-specific authorization.
/// </summary>
public abstract class BaseLedgerJournalController : ControllerBase
{
protected readonly IMediator _mediator;

protected BaseLedgerJournalController(IMediator mediator)
{
_mediator = mediator;
}

/// <summary>
/// Posts a ledger journal. Override in derived classes to apply specific authorization.
/// </summary>
protected async Task&lt;Results&lt;Ok, BadRequest&gt;&gt; PostJournalAsync(Guid journalId)
{
var command = new PostLedgerJournalCommand { LedgerJournalId = journalId };
return await _mediator.Send(command)
? TypedResults.Ok()
: TypedResults.BadRequest();
}

/// <summary>
/// Reverses a posted journal. Override in derived classes to apply specific authorization.
/// </summary>
protected async Task&lt;ActionResult&gt; ReverseJournalAsync(Guid journalId)
{
var command = new ReverseJournalCommand { JournalId = journalId };
return Ok(await _mediator.Send(command));
}

/// <summary>
/// Reverses a specific voucher. Override in derived classes to apply specific authorization.
/// </summary>
protected async Task&lt;ActionResult&lt;ReverseVoucherResult&gt;&gt; ReverseVoucherAsync(
Guid journalId,
string voucherNumber,
ReverseVoucherCommand command,
CancellationToken cancellationToken = default)
{
var result = await _mediator.Send(command, cancellationToken);
return Ok(result);
}

/// <summary>
/// Deletes an unposted journal. Override in derived classes to apply specific authorization.
/// </summary>
protected async Task&lt;ActionResult&gt; DeleteJournalAsync(Guid journalId)
{
var command = new DeleteLedgerJournalCommand { JournalId = journalId };
return Ok(await _mediator.Send(command));
}
}

2. Derived Controllers (Apply Specific Permissions)

// CustomerPaymentJournalController.cs

[ApiController]
[Route("general-journals/customer-payments")]
public class CustomerPaymentJournalController : BaseLedgerJournalController
{
public CustomerPaymentJournalController(IMediator mediator) : base(mediator) { }

// ... existing CRUD operations ...

/// <summary>
/// Posts a customer payment journal.
/// Requires 'post:customer-payment-journal' permission.
/// </summary>
[HttpPut("{journalId:guid}/post")]
[Authorize(Policy = CustomerPaymentJournalPermission.Post)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task&lt;Results&lt;Ok, BadRequest&gt;&gt; PostAsync([FromRoute] Guid journalId)
{
return await PostJournalAsync(journalId);
}

/// <summary>
/// Reverses a posted customer payment journal.
/// Requires 'reverse:customer-payment-journal' permission.
/// </summary>
[HttpPut("{journalId:guid}/reverse")]
[Authorize(Policy = CustomerPaymentJournalPermission.Reverse)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task&lt;ActionResult&gt; ReverseAsync([FromRoute] Guid journalId)
{
return await ReverseJournalAsync(journalId);
}

/// <summary>
/// Reverses a specific voucher in a customer payment journal.
/// Requires 'reverse:customer-payment-journal' permission.
/// </summary>
[HttpPost("{journalId:guid}/vouchers/{voucherNumber}/reverse")]
[Authorize(Policy = CustomerPaymentJournalPermission.Reverse)]
[ProducesResponseType(typeof(ReverseVoucherResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task&lt;ActionResult&lt;ReverseVoucherResult&gt;&gt; ReverseVoucherAsync(
[FromRoute] Guid journalId,
[FromRoute] string voucherNumber,
[FromBody] ReverseVoucherCommand command,
CancellationToken cancellationToken = default)
{
command.JournalId = journalId;
command.VoucherNumber = voucherNumber;
return await ReverseVoucherAsync(journalId, voucherNumber, command, cancellationToken);
}

/// <summary>
/// Deletes an unposted customer payment journal.
/// Requires 'delete:customer-payment-journal' permission.
/// </summary>
[HttpDelete("{journalId:guid}")]
[Authorize(Policy = CustomerPaymentJournalPermission.Delete)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task&lt;ActionResult&gt; DeleteAsync([FromRoute] Guid journalId)
{
return await DeleteJournalAsync(journalId);
}
}

3. Updated Permission Classes

Ensure each journal type has a Reverse permission:

// CustomerPaymentJournalPermission.cs

public class CustomerPaymentJournalPermission : IPermissionProvider
{
private const string Resource = "customer-payment-journal";

public static readonly Permission Create = new(Resource, "create", "Create a new Customer Payment Journal.");
public static readonly Permission Read = new(Resource, "read", "View Customer Payment Journals.");
public static readonly Permission Update = new(Resource, "update", "Update an unposted Customer Payment Journal.");
public static readonly Permission Delete = new(Resource, "delete", "Delete an unposted Customer Payment Journal.");
public static readonly Permission Post = new(Resource, "post", "Post a Customer Payment Journal.");

/// <summary>
/// Permission to reverse a posted customer payment journal.
/// </summary>
public static readonly Permission Reverse = new(Resource, "reverse", "Reverse a posted Customer Payment Journal.");
}

Benefits of This Approach

  1. Minimal Duplication: Core logic lives in base controller, derived controllers are thin wrappers
  2. Explicit Permissions: Each endpoint clearly shows its authorization requirement
  3. Type Safety: Compile-time checking of permission constants
  4. API Clarity: Each journal type has its own clear REST endpoints
  5. Maintainability: Bug fixes in base controller automatically propagate
  6. Discoverability: Swagger/OpenAPI generates separate endpoints per journal type
  7. Testability: Can test base controller logic once, test authorization separately

Alternative Patterns Considered

Option A: Custom Authorization Attribute with Journal Type

[HttpPut("{journalId:guid}/post")]
[RequireJournalPermission(JournalType.CustomerPayment, "post")]
public async Task&lt;Results&lt;Ok, BadRequest&gt;&gt; PostAsync([FromRoute] Guid journalId)
{
// Single shared endpoint
}

Rejected because:

  • Requires custom authorization infrastructure
  • Less explicit in Swagger/API documentation
  • Harder to audit permissions

Option B: Resource-Based Authorization

[HttpPut("{journalId:guid}/post")]
public async Task&lt;IActionResult&gt; PostAsync([FromRoute] Guid journalId)
{
var journal = await _journalRepository.GetAsync(journalId);
var authResult = await _authorizationService.AuthorizeAsync(User, journal, "Post");

if (!authResult.Succeeded)
return Forbid();

// Continue...
}

Rejected because:

  • Requires loading journal before authorization
  • Performance overhead
  • Less declarative

Implementation Checklist

  • Create BaseLedgerJournalController with protected methods
  • Update CustomerPaymentJournalController to inherit from base
  • Update VendorPaymentJournalController to inherit from base
  • Add Reverse permission to all journal type Permission classes
  • Update authorization policy registration in module startup
  • Update API documentation to reflect new endpoints
  • Add integration tests for journal-specific authorization
  • docs/domain/general-ledger/aggregates/ledger-journal-header.aggregate.md
  • docs/business-concepts/general-ledger/understanding-general-journals.md
  • src/Core/Shared/Authorization/Permission.cs