fix(auth): exact-match authorization; guard reviews; centralize name validation

Auth + input-validation cluster (CODE-REVIEW-2026-06 #5, #6, #16, #17
+ libra-#36, libra-#51, libra-#52):

- can_access_user_data compares full user ids only. The 8-char prefix
  comparison was a 32-bit space: any prefix collision (or a crafted
  short target id) let one user read another's data.
- can_access_account matches the User-{short} SEGMENT exactly; the
  substring test also matched accounts merely containing it
  (Expenses:Misc-User-deadbeef).
- Manual-payment approve/reject are status-guarded
  (UPDATE ... WHERE status='pending' + rowcount): concurrent admins
  can't double-book. The approve endpoint claims the request BEFORE
  writing the ledger entry and reverts the claim if the write fails,
  so at most one journal entry can exist per request.
- Account-name validation centralized into
  account_utils.validate_account_name (libra-#51) — called from
  crud.create_account (the choke point for every creation path,
  virtual parents allowed a bare root), the admin add-account
  endpoint, and fava_client.add_account at the writer boundary
  (libra-#52).
- crud.create_account translates backend unique-violations into
  AccountExistsError instead of leaking sqlalchemy internals
  (libra-#36); POST /accounts returns 409 on duplicates and 400 on
  malformed names. get_or_create_user_account catches the domain
  error instead of string-matching the SQLite message (which never
  matched on Postgres).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-07-12 15:52:29 +02:00
commit c0d371036b
6 changed files with 422 additions and 103 deletions

View file

@ -17,6 +17,58 @@ ACCOUNT_TYPE_ROOTS = {
AccountType.EXPENSE: "Expenses",
}
VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:")
def is_valid_account_component(component: str, *, is_root: bool) -> bool:
"""Validate one ':'-separated account component against Beancount's grammar.
Mirrors core/account.py: a root component matches ``[\\p{Lu}][\\p{L}\\p{Nd}-]*``
(must start with an uppercase letter); a sub component matches
``[\\p{Lu}\\p{Nd}][\\p{L}\\p{Nd}-]*`` (may also start with a digit). Body
chars are letters, decimal digits, or hyphen. Implemented with Unicode-aware
str methods (libra's runtime has no beancount — Fava is a separate service),
so non-ASCII letters are accepted exactly as Beancount accepts them.
"""
if not component:
return False
first, rest = component[0], component[1:]
first_ok = (first.isalpha() and first.isupper()) or (
not is_root and first.isdecimal()
)
if not first_ok:
return False
return all(ch == "-" or ch.isalpha() or ch.isdecimal() for ch in rest)
def validate_account_name(name: str, *, allow_root_only: bool = False) -> None:
"""Raise ValueError if ``name`` is not a syntactically valid Beancount account.
The single source of truth for account-name validation (libra-#51):
every path that writes an account name admin add-account, direct
account create, user-account derivation funnels through here before
the name can reach the ledger source.
Args:
name: Hierarchical account name (e.g. "Expenses:Food").
allow_root_only: Accept a bare root component ("Expenses")
only virtual parent accounts are allowed this shape.
"""
parts = name.split(":")
min_parts = 1 if allow_root_only else 2
valid = (
len(parts) >= min_parts
and is_valid_account_component(parts[0], is_root=True)
and all(is_valid_account_component(p, is_root=False) for p in parts[1:])
)
if not valid:
raise ValueError(
f"Invalid account name {name!r}: each ':'-separated part must be "
"letters/digits/hyphens, the root starting with an uppercase "
"letter (sub-accounts may start with a digit), with at least one "
"sub-account (e.g. Expenses:Food)."
)
def format_hierarchical_account_name(
account_type: AccountType,