Skip to content

ledger: any authenticated principal can read and write any book #382

Description

@Yaraslaut

Summary

ledger has no per-book ownership or authorization of any kind. Any principal
holding a valid token may read, write and post into any book in the
database, including one another principal created. There is no role table, no
owner column, and no action that consults either.

This is not new — it has been true since the rung shipped — but morph#361's
CreateLedger makes it reachable in a new way: a client can now create a book
over the wire, and the book it creates is immediately writable by every other
authenticated client.

What is enforced, and what is not

Enforced (examples/ledger/include/ledger/auth/ledger_authorizer.hpp's own
file comment is accurate about this):

  • SigningAuthorizer::authorize() verifies a real signed token on every
    execute other than AuthModel/Login.
  • Each mutating model action refuses an empty principal
    (EmptyPrincipalError), and RunReportJob refuses any principal but
    kReportRunnerPrincipal.

Not enforced: which principal a book belongs to. LedgerModel::execute(const OpenAccount&) checks only that the ledgers row exists;
execute(const GetLedger&) is a pure read with no principal check at all.
authorizeInstance is left at its permissive default, and the authorizer's
file comment gives the reason — LedgerModel's instances are keyed by
ledgerId and shared across every client that opens the same book, so there
is "no single owning caller for authorizeInstance to compare against".

Verification status

Reproduced over the wire on this branch (scenario-corpus-both-axes, with
morph#361's CreateLedger applied), against ladder_ledger_server on a fresh,
unseeded LEDGER_DB. Real output:

  ok   step 5 (line 11) [abooks] do CreateLedger name="Alice's private book"
       capture book = 1
  ok   step 6 (line 14) [abooks] do OpenAccount ledgerId=$book name="Alice Cash" kind=0 currency=1
       capture acash = 1
  ok   step 8 (line 19) [auth] do Login username=bob
       capture bob = bob
  ok   step 11 (line 27) [bbooks] do GetLedger ledgerId=$book
  ok   step 12 (line 31) [bbooks] do OpenAccount ledgerId=$book name="Bob's account in Alice's book" kind=1 currency=1
       capture bacct = 2
  ok   step 14 (line 36) [anon] do CreateLedger name="No token"

Bob reads Alice's book (GetLedger asserts accounts ~ "Alice Cash") and
writes into it (OpenAccount returns id 2 in Alice's book). The last step
confirms the boundary that does hold: a tokenless client is refused with
unauthorized.

Not verified: whether this was a deliberate scope cut. The authorizer's
file comment argues for leaving authorizeInstance permissive, but it argues
from "the model's instances are shared" — which is about instance ownership,
not about book ownership, and says nothing about GetLedger having no
principal check. I could not find a spec statement either way;
docs/superpowers/specs/2026-08-19-ledger-rung5-design.md §11 covers the
empty-principal gate and no more.

Not verified: whether a fix belongs at the authorizer or in each model
action. kanban's answer (a project_roles table plus a requireRole() helper
consulted by every action) is the closest precedent in the ladder, but it is a
schema change plus a gate on every ledger action, not a one-line fix.

Why record it rather than shrug

The rung's whole subject is money and an audit trail. "Any signed-in user may
post entries into any book" is the kind of gap that is invisible from inside
the test suite, because every test uses one principal and one book, and it is
now the first thing a second client of a freshly created book can do.

What would close this

Either a per-book authorization gate (an owner or a role table consulted by
LedgerModel/BudgetModel/RuleModel, kanban-style), or a sentence in
examples/ledger/README.md and the design spec stating plainly that this rung
models one trusted user and shares every book between all of them, so nobody
reads the empty-principal check as an ownership check.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: ladderSubsystem: ladderbugSomething isn't workingtriage: rescopeReal problem, wrong framing; rewrite before building

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions