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
18
auth.py
18
auth.py
|
|
@ -172,11 +172,14 @@ async def can_access_account(
|
|||
if auth.is_super_user:
|
||||
return True
|
||||
|
||||
# Check if this is the user's own account
|
||||
# Check if this is the user's own account. Match the User-{short}
|
||||
# segment exactly — a substring test also matched account names that
|
||||
# merely CONTAIN it (e.g. "Expenses:Misc-User-deadbeef"), granting
|
||||
# access to unrelated accounts.
|
||||
account = await get_account(account_id)
|
||||
if account:
|
||||
user_short = auth.user_id[:8]
|
||||
if f"User-{user_short}" in account.name:
|
||||
user_segment = f"User-{auth.user_id[:8]}"
|
||||
if user_segment in account.name.split(":"):
|
||||
return True
|
||||
|
||||
# Check explicit permissions
|
||||
|
|
@ -242,14 +245,13 @@ async def can_access_user_data(auth: AuthContext, target_user_id: str) -> bool:
|
|||
if auth.is_super_user:
|
||||
return True
|
||||
|
||||
# Users can access their own data - compare full ID or short ID
|
||||
# Users can access their own data. Full-ID equality ONLY: an 8-char
|
||||
# prefix comparison is a 32-bit space, and any prefix collision (or a
|
||||
# deliberately crafted short target id) let one user read another's
|
||||
# data. Callers must pass full user ids.
|
||||
if auth.user_id == target_user_id:
|
||||
return True
|
||||
|
||||
# Also allow if short IDs match (8 char prefix)
|
||||
if auth.user_id[:8] == target_user_id[:8]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue