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<Results<Ok, BadRequest>> 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<ActionResult> 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<ActionResult<ReverseVoucherResult>> 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<ActionResult> 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<Results<Ok, BadRequest>> 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<ActionResult> 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<ActionResult<ReverseVoucherResult>> 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<ActionResult> 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
- Minimal Duplication: Core logic lives in base controller, derived controllers are thin wrappers
- Explicit Permissions: Each endpoint clearly shows its authorization requirement
- Type Safety: Compile-time checking of permission constants
- API Clarity: Each journal type has its own clear REST endpoints
- Maintainability: Bug fixes in base controller automatically propagate
- Discoverability: Swagger/OpenAPI generates separate endpoints per journal type
- 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<Results<Ok, BadRequest>> 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<IActionResult> 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
BaseLedgerJournalControllerwith protected methods - Update
CustomerPaymentJournalControllerto inherit from base - Update
VendorPaymentJournalControllerto inherit from base - Add
Reversepermission 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
Related Documentation
docs/domain/general-ledger/aggregates/ledger-journal-header.aggregate.mddocs/business-concepts/general-ledger/understanding-general-journals.mdsrc/Core/Shared/Authorization/Permission.cs