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:
parent
4d63e08a69
commit
c0d371036b
6 changed files with 422 additions and 103 deletions
99
views_api.py
99
views_api.py
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue