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
207
tests/test_auth_validation.py
Normal file
207
tests/test_auth_validation.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""Auth narrowing + input validation.
|
||||
|
||||
Covers the PR-5 fixes:
|
||||
- `can_access_user_data`: full-ID equality only (an 8-char prefix
|
||||
comparison let prefix-colliding users read each other's data).
|
||||
- `can_access_account`: exact User-{short} SEGMENT match (a substring
|
||||
test also matched accounts merely containing it).
|
||||
- Manual-payment approve/reject: status-guarded claim — concurrent
|
||||
admins can't double-book (CODE-REVIEW-2026-06 #16).
|
||||
- POST /accounts: duplicate → 409 instead of a leaked
|
||||
IntegrityError 500 (libra-#36); malformed name → 400 (libra-#51).
|
||||
"""
|
||||
import asyncio
|
||||
import importlib
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from .helpers import submit_manual_payment_request
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _module(name: str):
|
||||
for prefix in ("lnbits.extensions.libra", "libra"):
|
||||
try:
|
||||
return importlib.import_module(f"{prefix}.{name}")
|
||||
except ModuleNotFoundError:
|
||||
continue
|
||||
raise ModuleNotFoundError(f"libra.{name}: tried both import paths")
|
||||
|
||||
|
||||
auth = _module("auth")
|
||||
libra_crud = _module("crud")
|
||||
mdl = _module("models")
|
||||
|
||||
|
||||
def _ctx(user_id: str) -> "auth.AuthContext":
|
||||
return auth.AuthContext(
|
||||
user_id=user_id, wallet_id="w", is_super_user=False, wallet=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# can_access_user_data — full-ID equality
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_prefix_colliding_user_cannot_access_other_users_data(client):
|
||||
caller = "deadbeef" + uuid4().hex[8:]
|
||||
victim = "deadbeef" + uuid4().hex[8:] # same 8-char prefix, different id
|
||||
assert victim != caller
|
||||
|
||||
assert await auth.can_access_user_data(_ctx(caller), caller) is True
|
||||
assert await auth.can_access_user_data(_ctx(caller), victim) is False
|
||||
# A crafted short target id must not match either.
|
||||
assert await auth.can_access_user_data(_ctx(caller), caller[:8]) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# can_access_account — exact segment match
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_account_access_requires_exact_user_segment(client):
|
||||
user_id = "deadbeef" + uuid4().hex[8:]
|
||||
suffix = uuid4().hex[:6]
|
||||
|
||||
# An account whose LAST SEGMENT merely contains "User-deadbeef".
|
||||
lookalike = await libra_crud.create_account(
|
||||
mdl.CreateAccount(
|
||||
name=f"Expenses:Misc-User-deadbeef-{suffix}",
|
||||
account_type=mdl.AccountType.EXPENSE,
|
||||
description="substring-match bait",
|
||||
)
|
||||
)
|
||||
owned = await libra_crud.create_account(
|
||||
mdl.CreateAccount(
|
||||
name=f"Assets:Receivable-{suffix}:User-deadbeef",
|
||||
account_type=mdl.AccountType.ASSET,
|
||||
description="genuinely owned",
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
|
||||
ctx = _ctx(user_id)
|
||||
assert await auth.can_access_account(
|
||||
ctx, lookalike.id, mdl.PermissionType.READ
|
||||
) is False, "substring-only match must not grant access"
|
||||
assert await auth.can_access_account(
|
||||
ctx, owned.id, mdl.PermissionType.READ
|
||||
) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manual payment approve/reject — status-guarded claim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_concurrent_approvals_create_exactly_one_entry(
|
||||
client, super_user_headers, configured_user,
|
||||
):
|
||||
_, wallet = configured_user
|
||||
submitted = await submit_manual_payment_request(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
amount_sats=10_000,
|
||||
description=f"race {uuid4().hex[:6]}",
|
||||
)
|
||||
|
||||
r1, r2 = await asyncio.gather(
|
||||
client.post(
|
||||
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
|
||||
headers=super_user_headers,
|
||||
),
|
||||
client.post(
|
||||
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
|
||||
headers=super_user_headers,
|
||||
),
|
||||
)
|
||||
statuses = sorted([r1.status_code, r2.status_code])
|
||||
assert statuses[0] == 200, f"one approval must win: {statuses} {r1.text} {r2.text}"
|
||||
assert statuses[1] in (400, 409), (
|
||||
f"the losing approval must fail cleanly, got {statuses}"
|
||||
)
|
||||
|
||||
# Exactly one ledger entry references this request.
|
||||
listing = await client.get(
|
||||
"/libra/api/v1/entries/user",
|
||||
headers={"X-Api-Key": wallet.inkey},
|
||||
)
|
||||
assert listing.status_code == 200
|
||||
link = f"MPR-{submitted['id']}"
|
||||
matching = [
|
||||
e for e in listing.json()["entries"] if link in (e.get("links") or [])
|
||||
]
|
||||
assert len(matching) == 1, (
|
||||
f"expected exactly one journal entry for {link}, got {len(matching)}"
|
||||
)
|
||||
|
||||
|
||||
async def test_reject_after_approve_conflicts(
|
||||
client, super_user_headers, configured_user,
|
||||
):
|
||||
_, wallet = configured_user
|
||||
submitted = await submit_manual_payment_request(
|
||||
client,
|
||||
wallet_inkey=wallet.inkey,
|
||||
amount_sats=5_000,
|
||||
description=f"approve-then-reject {uuid4().hex[:6]}",
|
||||
)
|
||||
|
||||
r = await client.post(
|
||||
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/approve",
|
||||
headers=super_user_headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = await client.post(
|
||||
f"/libra/api/v1/manual-payment-requests/{submitted['id']}/reject",
|
||||
headers=super_user_headers,
|
||||
)
|
||||
assert r.status_code in (400, 409), (
|
||||
f"rejecting an approved request must fail, got {r.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /accounts — duplicate and malformed names
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_duplicate_account_returns_409(
|
||||
client, super_user_headers,
|
||||
):
|
||||
name = f"Expenses:DupTest-{uuid4().hex[:6]}"
|
||||
body = {"name": name, "account_type": "expense", "description": "dup test"}
|
||||
|
||||
r = await client.post(
|
||||
"/libra/api/v1/accounts", headers=super_user_headers, json=body,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
|
||||
r = await client.post(
|
||||
"/libra/api/v1/accounts", headers=super_user_headers, json=body,
|
||||
)
|
||||
assert r.status_code == 409, (
|
||||
f"duplicate create must 409, not leak an IntegrityError: "
|
||||
f"{r.status_code} {r.text}"
|
||||
)
|
||||
|
||||
|
||||
async def test_create_account_with_invalid_name_returns_400(
|
||||
client, super_user_headers,
|
||||
):
|
||||
r = await client.post(
|
||||
"/libra/api/v1/accounts",
|
||||
headers=super_user_headers,
|
||||
json={
|
||||
"name": 'Expenses:bad"name\nfoo',
|
||||
"account_type": "expense",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400, (
|
||||
f"malformed account name must 400 before reaching the ledger: "
|
||||
f"{r.status_code} {r.text}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue