Custom controllers with Palmyra ACL#

Palmyra handlers cover the CRUD surface. Non-CRUD actions — multi-record payments, approve/reject workflows, custom aggregation endpoints — belong in plain Spring @RestControllers. The two patterns coexist: handlers own the data, controllers own the actions.

The boundary#

Use a handler when… Use a custom controller when…
The operation maps to a single entity’s CRUD lifecycle The operation spans multiple entities in one call
Pagination, sort, search, export are needed The request shape doesn’t fit the standard query-param contract
@CrudMapping + handler interfaces cover the surface The endpoint needs a custom URL, custom request body, or a non-JSON response

Securing custom controllers with Palmyra’s ACL#

@PreAuthorize("hasPermission(...)") flows through the same PalmyraPermissionEvaluator that resolves @Permission on handlers. The ACL tables are the single source of truth for both surfaces.

@RestController
@RequestMapping("/api/payment")
@RequiredArgsConstructor
public class PaymentController {

    private final PaymentService paymentService;

    @PostMapping
    @PreAuthorize("hasPermission(#request, 'PAYMENT_CREATE')")
    public ResponseEntity<?> createPayment(@RequestBody PaymentRequest request) {
        // request.invoiceIds may contain 5–20 invoices paid in one transaction
        var result = paymentService.processMultiInvoicePayment(request);
        return ResponseEntity.ok(result);
    }

    @PostMapping("/{id}/approve")
    @PreAuthorize("hasPermission(#id, 'Payment', 'PAYMENT_APPROVE')")
    public ResponseEntity<?> approve(@PathVariable Long id) {
        paymentService.approve(id);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/{id}/reject")
    @PreAuthorize("hasPermission(#id, 'Payment', 'PAYMENT_REJECT')")
    public ResponseEntity<?> reject(@PathVariable Long id, @RequestBody RejectReason reason) {
        paymentService.reject(id, reason);
        return ResponseEntity.noContent().build();
    }
}

The request DTO#

Custom endpoints carry domain-shaped DTOs, not Palmyra tuples:

@Data
public class PaymentRequest {
    private Long        vendorId;
    private LocalDate   paymentDate;
    private String      paymentMode;       // CHEQUE, NEFT, RTGS
    private String      referenceNumber;
    private List<Long>  invoiceIds;        // bulk: pay multiple invoices at once
    private BigDecimal  totalAmount;
}

Coexistence with handlers#

The same entity can have both. The handler serves the standard CRUD surface; the controller serves the actions.

// Standard CRUD — paginated list, single-record read, upsert
@Component
@CrudMapping(mapping = "/payment", type = PaymentModel.class,
             secondaryMapping = "/payment/{id}")
public class PaymentHandler extends AbstractHandler
        implements QueryHandler, ReadHandler, SaveHandler { }

// Actions — multi-invoice creation, approve, reject
@RestController
@RequestMapping("/api/payment")
public class PaymentController { /* ... */ }

Both resolve permissions through the same ACL tables — @Permission on the handler and @PreAuthorize("hasPermission(...)") on the controller call the same PermissionEvaluator.

See also: PalmyraPermissionEvaluator, @Permission.