Skip to content

Add event sourcing to the book and tutorial, and enforce wallet ownership in the sample - #19

Merged
ancongui merged 25 commits into
mainfrom
docs/eventsourcing-book
Oct 13, 2026
Merged

ancongui merged 25 commits into
mainfrom
docs/eventsourcing-book

Conversation

@ancongui

Copy link
Copy Markdown
Contributor

Adds event sourcing to larafly by example (EN + ES) and the tutorial, and fixes the authorization defects that writing it uncovered.

Book, tutorial and sample

  • New Chapter 9B, Event Sourcing. The Lumen sample's loan is event-sourced and stored in the database event store:

    • rebuilt from its history and from snapshots;
    • a checkpointed loan-book projection;
    • an upcaster for a renamed repayment field;
    • loan events published through the outbox in the append's transaction;
    • the payout saga on firefly.orchestration.persistence.provider=event-sourced.

    Every listing is real code with tests on every integration lane.

  • Supporting pages. Updated the glossary, Appendix A, preface, Chapter 9A, book/README.md and both manifests, and swept the book for statements W3 made false.

  • Tutorial Step 14 (EN + ES). Greetings are recorded as events and projected into a read model. The code is proven as the reader's own App\ files. "What's Next" moves to Step 15.

  • Site. Getting started, the index and the README now name event sourcing.

Fixes

  • Wallet ownership (security). The sample authorized debits by role only, so any WALLET_OWNER could withdraw from, pay out of, repay a loan against, or transfer from any wallet. TransferHandler had no guard at all, so even anonymous callers could transfer.
    • A new WalletPermissionEvaluator (a PermissionEvaluator bean) grants debit only to the wallet's owner; anything it does not recognize is refused.
    • Every debit path (withdraw, transfer, loan repayment, payout) now requires hasRole('ADMIN') or (hasRole('WALLET_OWNER') and hasPermission(#command, 'debit')).
    • Tests cover each case: anonymous gets 401, a non-owner gets 403 and nothing moves, and the owner and an admin succeed.
  • Problem documents over the CQRS bus (framework). CommandProcessingException and QueryProcessingException dropped the cause's extension members and title. A method-security refusal raised through the bus therefore lost its documented requiredAuthorities. Both wrappers now carry them through, and a regression test requires the bus and controller documents to be identical apart from detail.
  • Test harness. The Lumen integration harness clears the security context only for a booted lane. Before, a skipped lane failed in its afterEach.

Verification

  • composer check: exit 0, 6782 passed.
  • Integration lanes: 386 passed. The Lumen samples pass on PostgreSQL, MySQL, MariaDB, SQLite and SQL Server.
  • bash scripts/build-docs.sh: exit 0.

Andres Contreras added 25 commits October 13, 2026 12:01
…m history and snapshots, refuse a stale writer
…he projection commands run, list and rebuild
…nd the loan book agree on the renamed field
…ppend's transaction, and roll them back with it
…tore and rebuild its read model from the streams
…nConfiguration adjacent, as Chapter 12 quotes them
…r and outbox publication in the sample README
…s, a checkpointed projection, an upcaster and outbox publication, in both editions
… into a read model; What's Next becomes Step 15, in both languages
…WalletPermissionEvaluator behind hasPermission(#command, 'debit')

WithdrawHandler and RepayLoanHandler were guarded by hasRole('ADMIN') or
hasRole('WALLET_OWNER'), so any customer holding the role could withdraw from,
pay out of, or repay a loan against any wallet. The rule now also asks
hasPermission(#command, 'debit'), answered by a sample PermissionEvaluator bean
that displaces the deny-all default and grants the debit only when the wallet's
owner_id is the principal's name (for RepayLoan, the wallet the loan was paid
into), failing closed on unknown targets, permissions and ids.
… WalletPermissionEvaluator, and Chapters 4, 6, 9A, 9B and the README follow, in both editions
…only the source wallet's owner or an admin can move money out of it

POST /api/v1/wallets/transfers had no #[PreAuthorize]: anyone, even an
anonymous caller, could debit any wallet by naming it as a transfer's source.
TransferHandler now carries the same rule as WithdrawHandler and
RepayLoanHandler, and WalletPermissionEvaluator answers 'debit' on a Transfer
for the owner of its source wallet only (owning the destination grants
nothing; every other target still fails closed).

The tests that transferred without a principal now act as the source
wallet's owner and keep their assertions; the two rollback tests also assert
that the cause is the credit leg's ConflictException, so a security refusal
can no longer make them pass for the wrong reason. New coverage: an anonymous
transfer is a 401 and a non-owner's a 403 problem+json with no balance moved
on either wallet, and the owner and an ADMIN succeed, over REST and the bus.
Chapter 4's WalletController listing follows the class comment.
…, 9 and 10 and the README, and Chapter 10's exercise 2 now guards a read instead, in both editions

The TransferHandler listings (Chapter 6, README) show the #[PreAuthorize]
line and its comment; Chapter 9's TransferSecurityTest excerpt signs in as the
source wallet's owner and says why the rollback test asserts its cause;
Chapter 10's WalletPermissionEvaluator listing names the Transfer target, the
proof section shows the non-owner transfer refused with no money moved, and
exercise 2, which asked the reader to add this very guard, now asks them to
guard GetBalance with a 'view' permission and count what that costs.
…rry the cause's RFC 9457 extension members and title, so a refused command publishes requiredAuthorities like a refused controller method

The fix is in the framework, not the book. MethodSecurityRefusal puts the
rule's authorities on the AuthorizationException as `requiredAuthorities`
on purpose: its class comment says the wire gets them "as an RFC 9457
extension member so a client can render 'you need ROLE_MANAGER'", and
docs/modules/security.md documents the member on every method-security
denial. Nothing omits it for security reasons; only the class, method and
expression are kept off the wire, and they still are. The member was lost by
accident: DefaultCommandBus wraps every failure in CommandProcessingException,
which copied the cause's code, status, category and severity but not its
extensions or title, and ProblemDetailsRenderer builds the document from the
wrapper. So the same rule refusing the same principal published the member
from a controller and dropped it from a command (Chapter 4's 403 for a
refused withdraw showed it; the real response did not carry it).

Both wrappers now pass the cause's extensions() and title() to the parent,
which also keeps a `field`, `allowed` or own title of any FireflyException a
handler throws. `detail` is unchanged: it still names the command, which the
book and docs already show. Regression tests: the cqrs taxonomy test pins
both wrappers (and the empty case), packages/security renders a controller
refusal and a real bus refusal through the real renderer and requires the
same document apart from detail, and Lumen's REST suite asserts the member on
a refused withdraw.
…ers 4, 7 and 10 and the cqrs and security module guides say a refused command keeps requiredAuthorities, in both editions
…skipped lane no longer reaches for an absent application
@ancongui
ancongui merged commit 57f987d into main Oct 13, 2026
12 checks passed
@ancongui ancongui mentioned this pull request Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant