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

@ -13,6 +13,7 @@ from lnbits.decorators import (
)
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
from .account_utils import VALID_ACCOUNT_PREFIXES, validate_account_name
from .beancount_format import fiat_rate_metadata
from .crud import (
approve_manual_payment_request,
@ -295,7 +296,19 @@ async def api_create_account(
auth: AuthContext = Depends(require_super_user),
) -> Account:
"""Create a new account (super user only)"""
return await create_account(data)
from .crud import AccountExistsError
try:
return await create_account(data)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(e)
)
except AccountExistsError:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail=f"Account {data.name} already exists",
)
@libra_api_router.get("/api/v1/accounts/{account_id}")
@ -2856,15 +2869,30 @@ async def api_approve_manual_payment_request(
settled_entry_links=settled_links
)
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Manual payment entry submitted to Fava: {result.get('data', 'Unknown')}")
# Approve the request with Fava entry reference
entry_id = f"fava-{datetime.now().timestamp()}"
return await approve_manual_payment_request(
request_id, auth.user_id, entry_id
# Claim the request BEFORE writing the ledger entry — the
# status-guarded UPDATE makes exactly one concurrent admin win, so
# only one journal entry can ever be created for this request.
approved = await approve_manual_payment_request(
request_id, auth.user_id, f"MPR-{request.id}"
)
if approved is None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Request was already reviewed by another admin",
)
try:
result = await fava.add_entry(entry)
logger.info(f"Manual payment entry submitted to Fava: {result.get('data', 'Unknown')}")
except BaseException:
# Ledger write failed — release the claim so the request can be
# approved again.
from .crud import revert_manual_payment_request
await revert_manual_payment_request(request_id)
raise
return approved
@libra_api_router.post("/api/v1/manual-payment-requests/{request_id}/reject")
@ -2887,7 +2915,13 @@ async def api_reject_manual_payment_request(
detail=f"Request already {request.status}",
)
return await reject_manual_payment_request(request_id, auth.user_id)
rejected = await reject_manual_payment_request(request_id, auth.user_id)
if rejected is None:
raise HTTPException(
status_code=HTTPStatus.CONFLICT,
detail="Request was already reviewed by another admin",
)
return rejected
# ===== EXPENSE APPROVAL ENDPOINTS =====
@ -3650,52 +3684,21 @@ async def api_get_account_hierarchy(
# ===== ACCOUNT SYNC ENDPOINTS =====
_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)
_VALID_ACCOUNT_PREFIXES = VALID_ACCOUNT_PREFIXES
def _validate_account_name(name: str) -> None:
"""Raise HTTP 400 if ``name`` is not a syntactically valid Beancount account.
The UI guards this client-side, but the endpoint is reachable directly via
API, so this is the load-bearing check before the name is written into the
ledger source. Requires a root plus at least one sub-component.
Thin HTTP wrapper around account_utils.validate_account_name the
single source of truth for account-name syntax (libra-#51).
"""
parts = name.split(":")
valid = (
len(parts) >= 2
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:
try:
validate_account_name(name)
except ValueError as e:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
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)."
),
detail=str(e),
)