Gate cross-user admin endpoints behind require_super_user

Twenty-six endpoints documented "(admin only)" were using
require_admin_key, which only checks the caller owns a wallet with
its admin key — not LNbits-instance admin. Any logged-in user could
fabricate receivables against any other user_id, grant themselves
MANAGE permission on any account, create + self-assign privileged
roles, etc.

Swaps Depends(require_admin_key) -> Depends(require_super_user) on:
receivable/revenue creation, equity-eligibility grant/revoke/list,
permission grant/list/revoke/bulk/bulk-grant, account-sync admin,
role + role-permission + user-role CRUD, cross-user contributions
and unsettled-entries reports.

Also deletes the unsafe duplicate /api/v1/pay-user — both function
defs shared the name api_pay_user, the second shadowed the first at
module scope but FastAPI registered both routes. /api/v1/payables/pay
already provides the super-user-gated equivalent.

Two pre-existing orphan wallet.wallet.user references inside
api_settle_receivable and api_approve_manual_payment_request (both
already used the auth parameter) would have raised NameError at
runtime; fixed in passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-05 22:57:59 +02:00
commit 1201557f0c

View file

@ -9,7 +9,6 @@ from lnbits.core.models import User, WalletTypeInfo
from lnbits.decorators import ( from lnbits.decorators import (
check_super_user, check_super_user,
check_user_exists, check_user_exists,
require_admin_key,
require_invoice_key, require_invoice_key,
) )
from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis from lnbits.utils.exchange_rates import allowed_currencies, fiat_amount_as_satoshis
@ -1334,7 +1333,7 @@ async def api_create_income_entry(
@libra_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/entries/receivable", status_code=HTTPStatus.CREATED)
async def api_create_receivable_entry( async def api_create_receivable_entry(
data: ReceivableEntry, data: ReceivableEntry,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> JournalEntry: ) -> JournalEntry:
""" """
Create an accounts receivable entry (user owes libra). Create an accounts receivable entry (user owes libra).
@ -1433,7 +1432,7 @@ async def api_create_receivable_entry(
id=entry_id, # Use the generated libra entry ID id=entry_id, # Use the generated libra entry ID
description=data.description + description_suffix, description=data.description + description_suffix,
entry_date=datetime.now(), entry_date=datetime.now(),
created_by=wallet.wallet.user, # Use user_id, not wallet_id created_by=auth.user_id,
created_at=datetime.now(), created_at=datetime.now(),
reference=libra_reference, # Use libra reference with unique ID reference=libra_reference, # Use libra reference with unique ID
flag=JournalEntryFlag.PENDING, flag=JournalEntryFlag.PENDING,
@ -1462,7 +1461,7 @@ async def api_create_receivable_entry(
@libra_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/entries/revenue", status_code=HTTPStatus.CREATED)
async def api_create_revenue_entry( async def api_create_revenue_entry(
data: RevenueEntry, data: RevenueEntry,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> JournalEntry: ) -> JournalEntry:
""" """
Create a revenue entry (libra receives payment). Create a revenue entry (libra receives payment).
@ -1547,7 +1546,7 @@ async def api_create_revenue_entry(
id=entry_id, id=entry_id,
description=data.description, description=data.description,
entry_date=datetime.now(), entry_date=datetime.now(),
created_by=wallet.wallet.user, # Use user_id, not wallet_id created_by=auth.user_id,
created_at=datetime.now(), created_at=datetime.now(),
reference=libra_reference, reference=libra_reference,
flag=JournalEntryFlag.CLEARED, flag=JournalEntryFlag.CLEARED,
@ -1934,65 +1933,6 @@ async def api_record_payment(
} }
@libra_api_router.post("/api/v1/pay-user")
async def api_pay_user(
user_id: str,
amount: int,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""
Record a payment from libra to user (reduces what libra owes user).
Admin only.
"""
# Get user's payable account (what libra owes)
user_payable = await get_or_create_user_account(
user_id, AccountType.LIABILITY, "Accounts Payable"
)
# Get lightning account
lightning_account = await get_account_by_name("Assets:Bitcoin:Lightning")
if not lightning_account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Lightning account not found"
)
# Format payment entry and submit to Fava
# DR Liabilities:Payable (User), CR Assets:Bitcoin:Lightning
from .fava_client import get_fava_client
from .beancount_format import format_payment_entry
fava = get_fava_client()
# Get unsettled expense entries to link to this settlement
unsettled = await fava.get_unsettled_entries_bql(user_id, "expense")
settled_links = [e["link"] for e in unsettled if e.get("link")]
entry = format_payment_entry(
user_id=user_id,
payment_account=lightning_account.name,
payable_or_receivable_account=user_payable.name,
amount_sats=amount,
description=f"Payment to user {user_id[:8]}",
entry_date=datetime.now().date(),
is_payable=True, # Libra paying user
reference=f"PAY-{user_id[:8]}",
settled_entry_links=settled_links
)
# Submit to Fava
result = await fava.add_entry(entry)
logger.info(f"Payment submitted to Fava: {result.get('data', 'Unknown')}")
# Get updated balance from Fava
balance_data = await fava.get_user_balance_bql(user_id)
return {
"journal_entry_id": f"fava-{datetime.now().timestamp()}",
"new_balance": balance_data["balance"],
"message": "Payment recorded successfully",
}
@libra_api_router.post("/api/v1/receivables/settle") @libra_api_router.post("/api/v1/receivables/settle")
async def api_settle_receivable( async def api_settle_receivable(
data: SettleReceivable, data: SettleReceivable,
@ -2119,7 +2059,7 @@ async def api_settle_receivable(
if "meta" not in entry: if "meta" not in entry:
entry["meta"] = {} entry["meta"] = {}
entry["meta"]["payment-method"] = data.payment_method entry["meta"]["payment-method"] = data.payment_method
entry["meta"]["settled-by"] = wallet.wallet.user entry["meta"]["settled-by"] = auth.user_id
if data.txid: if data.txid:
entry["meta"]["txid"] = data.txid entry["meta"]["txid"] = data.txid
@ -2493,7 +2433,7 @@ async def api_expense_report(
@libra_api_router.get("/api/v1/reports/contributions") @libra_api_router.get("/api/v1/reports/contributions")
async def api_contributions_report( async def api_contributions_report(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
""" """
Get user contribution report using BQL. Get user contribution report using BQL.
@ -2562,7 +2502,7 @@ async def api_contributions_report(
async def api_get_unsettled_entries( async def api_get_unsettled_entries(
user_id: str, user_id: str,
entry_type: str = "expense", entry_type: str = "expense",
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
""" """
Get unsettled expense or receivable entries for a user. Get unsettled expense or receivable entries for a user.
@ -2770,7 +2710,7 @@ async def api_approve_manual_payment_request(
# Approve the request with Fava entry reference # Approve the request with Fava entry reference
entry_id = f"fava-{datetime.now().timestamp()}" entry_id = f"fava-{datetime.now().timestamp()}"
return await approve_manual_payment_request( return await approve_manual_payment_request(
request_id, wallet.wallet.user, entry_id request_id, auth.user_id, entry_id
) )
@ -3344,18 +3284,18 @@ async def api_get_user_info(
@libra_api_router.post("/api/v1/admin/equity-eligibility", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/admin/equity-eligibility", status_code=HTTPStatus.CREATED)
async def api_grant_equity_eligibility( async def api_grant_equity_eligibility(
data: CreateUserEquityStatus, data: CreateUserEquityStatus,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> UserEquityStatus: ) -> UserEquityStatus:
"""Grant equity contribution eligibility to a user (admin only)""" """Grant equity contribution eligibility to a user (admin only)"""
from .crud import create_or_update_user_equity_status from .crud import create_or_update_user_equity_status
return await create_or_update_user_equity_status(data, wallet.wallet.user) return await create_or_update_user_equity_status(data, auth.user_id)
@libra_api_router.delete("/api/v1/admin/equity-eligibility/{user_id}") @libra_api_router.delete("/api/v1/admin/equity-eligibility/{user_id}")
async def api_revoke_equity_eligibility( async def api_revoke_equity_eligibility(
user_id: str, user_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> UserEquityStatus: ) -> UserEquityStatus:
"""Revoke equity contribution eligibility from a user (admin only)""" """Revoke equity contribution eligibility from a user (admin only)"""
from .crud import revoke_user_equity_eligibility from .crud import revoke_user_equity_eligibility
@ -3371,7 +3311,7 @@ async def api_revoke_equity_eligibility(
@libra_api_router.get("/api/v1/admin/equity-eligibility") @libra_api_router.get("/api/v1/admin/equity-eligibility")
async def api_list_equity_eligible_users( async def api_list_equity_eligible_users(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list[UserEquityStatus]: ) -> list[UserEquityStatus]:
"""List all equity-eligible users (admin only)""" """List all equity-eligible users (admin only)"""
from .crud import get_all_equity_eligible_users from .crud import get_all_equity_eligible_users
@ -3385,7 +3325,7 @@ async def api_list_equity_eligible_users(
@libra_api_router.post("/api/v1/admin/permissions", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/admin/permissions", status_code=HTTPStatus.CREATED)
async def api_grant_permission( async def api_grant_permission(
data: CreateAccountPermission, data: CreateAccountPermission,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> AccountPermission: ) -> AccountPermission:
"""Grant account permission to a user (admin only)""" """Grant account permission to a user (admin only)"""
# Validate that account exists # Validate that account exists
@ -3396,14 +3336,14 @@ async def api_grant_permission(
detail=f"Account with ID '{data.account_id}' not found", detail=f"Account with ID '{data.account_id}' not found",
) )
return await create_account_permission(data, wallet.wallet.user) return await create_account_permission(data, auth.user_id)
@libra_api_router.get("/api/v1/admin/permissions") @libra_api_router.get("/api/v1/admin/permissions")
async def api_list_permissions( async def api_list_permissions(
user_id: str | None = None, user_id: str | None = None,
account_id: str | None = None, account_id: str | None = None,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list[AccountPermission]: ) -> list[AccountPermission]:
""" """
List account permissions (admin only). List account permissions (admin only).
@ -3436,7 +3376,7 @@ async def api_list_permissions(
@libra_api_router.delete("/api/v1/admin/permissions/{permission_id}") @libra_api_router.delete("/api/v1/admin/permissions/{permission_id}")
async def api_revoke_permission( async def api_revoke_permission(
permission_id: str, permission_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
"""Revoke (delete) an account permission (admin only)""" """Revoke (delete) an account permission (admin only)"""
# Verify permission exists # Verify permission exists
@ -3458,7 +3398,7 @@ async def api_revoke_permission(
@libra_api_router.post("/api/v1/admin/permissions/bulk", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/admin/permissions/bulk", status_code=HTTPStatus.CREATED)
async def api_bulk_grant_permissions( async def api_bulk_grant_permissions(
permissions: list[CreateAccountPermission], permissions: list[CreateAccountPermission],
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list[AccountPermission]: ) -> list[AccountPermission]:
"""Grant multiple account permissions at once (admin only)""" """Grant multiple account permissions at once (admin only)"""
created_permissions = [] created_permissions = []
@ -3472,7 +3412,7 @@ async def api_bulk_grant_permissions(
detail=f"Account with ID '{perm_data.account_id}' not found", detail=f"Account with ID '{perm_data.account_id}' not found",
) )
perm = await create_account_permission(perm_data, wallet.wallet.user) perm = await create_account_permission(perm_data, auth.user_id)
created_permissions.append(perm) created_permissions.append(perm)
return created_permissions return created_permissions
@ -3481,7 +3421,7 @@ async def api_bulk_grant_permissions(
@libra_api_router.post("/api/v1/admin/permissions/bulk-grant", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/admin/permissions/bulk-grant", status_code=HTTPStatus.CREATED)
async def api_bulk_grant_permission_to_users( async def api_bulk_grant_permission_to_users(
data: "BulkGrantPermission", data: "BulkGrantPermission",
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> "BulkGrantResult": ) -> "BulkGrantResult":
""" """
Grant the same permission to multiple users at once (admin only). Grant the same permission to multiple users at once (admin only).
@ -3515,7 +3455,7 @@ async def api_bulk_grant_permission_to_users(
expires_at=data.expires_at, expires_at=data.expires_at,
notes=data.notes, notes=data.notes,
) )
perm = await create_account_permission(perm_data, wallet.wallet.user) perm = await create_account_permission(perm_data, auth.user_id)
granted.append(perm) granted.append(perm)
except Exception as e: except Exception as e:
failed.append({ failed.append({
@ -3628,7 +3568,7 @@ async def api_get_account_hierarchy(
@libra_api_router.post("/api/v1/admin/accounts/sync") @libra_api_router.post("/api/v1/admin/accounts/sync")
async def api_sync_all_accounts( async def api_sync_all_accounts(
force_full_sync: bool = False, force_full_sync: bool = False,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
""" """
Sync all accounts from Beancount to Libra DB (admin only). Sync all accounts from Beancount to Libra DB (admin only).
@ -3644,7 +3584,7 @@ async def api_sync_all_accounts(
""" """
from .account_sync import sync_accounts_from_beancount from .account_sync import sync_accounts_from_beancount
logger.info(f"Admin {wallet.wallet.user[:8]} triggered account sync (force={force_full_sync})") logger.info(f"Admin {auth.user_id[:8]} triggered account sync (force={force_full_sync})")
try: try:
stats = await sync_accounts_from_beancount(force_full_sync=force_full_sync) stats = await sync_accounts_from_beancount(force_full_sync=force_full_sync)
@ -3661,7 +3601,7 @@ async def api_sync_all_accounts(
@libra_api_router.post("/api/v1/admin/accounts/sync/{account_name:path}") @libra_api_router.post("/api/v1/admin/accounts/sync/{account_name:path}")
async def api_sync_single_account( async def api_sync_single_account(
account_name: str, account_name: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> dict: ) -> dict:
""" """
Sync a single account from Beancount to Libra DB (admin only). Sync a single account from Beancount to Libra DB (admin only).
@ -3677,7 +3617,7 @@ async def api_sync_single_account(
""" """
from .account_sync import sync_single_account_from_beancount from .account_sync import sync_single_account_from_beancount
logger.info(f"Admin {wallet.wallet.user[:8]} triggered sync for account: {account_name}") logger.info(f"Admin {auth.user_id[:8]} triggered sync for account: {account_name}")
try: try:
created = await sync_single_account_from_beancount(account_name) created = await sync_single_account_from_beancount(account_name)
@ -3707,7 +3647,7 @@ async def api_sync_single_account(
@libra_api_router.get("/api/v1/admin/roles") @libra_api_router.get("/api/v1/admin/roles")
async def api_get_all_roles( async def api_get_all_roles(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
) -> list: ) -> list:
"""Get all roles (admin only)""" """Get all roles (admin only)"""
from . import crud from . import crud
@ -3737,13 +3677,13 @@ async def api_get_all_roles(
@libra_api_router.post("/api/v1/admin/roles", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/admin/roles", status_code=HTTPStatus.CREATED)
async def api_create_role( async def api_create_role(
data: CreateRole, data: CreateRole,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Create a new role (admin only)""" """Create a new role (admin only)"""
from . import crud from . import crud
try: try:
role = await crud.create_role(data, created_by=wallet.wallet.user) role = await crud.create_role(data, created_by=auth.user_id)
return { return {
"id": role.id, "id": role.id,
"name": role.name, "name": role.name,
@ -3763,7 +3703,7 @@ async def api_create_role(
@libra_api_router.get("/api/v1/admin/roles/{role_id}") @libra_api_router.get("/api/v1/admin/roles/{role_id}")
async def api_get_role( async def api_get_role(
role_id: str, role_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Get a specific role with its permissions and users (admin only)""" """Get a specific role with its permissions and users (admin only)"""
from . import crud from . import crud
@ -3813,7 +3753,7 @@ async def api_get_role(
async def api_update_role( async def api_update_role(
role_id: str, role_id: str,
data: UpdateRole, data: UpdateRole,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Update a role (admin only)""" """Update a role (admin only)"""
from . import crud from . import crud
@ -3838,7 +3778,7 @@ async def api_update_role(
@libra_api_router.delete("/api/v1/admin/roles/{role_id}") @libra_api_router.delete("/api/v1/admin/roles/{role_id}")
async def api_delete_role( async def api_delete_role(
role_id: str, role_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Delete a role (admin only) - cascades to role_permissions and user_roles""" """Delete a role (admin only) - cascades to role_permissions and user_roles"""
from . import crud from . import crud
@ -3861,7 +3801,7 @@ async def api_delete_role(
async def api_add_role_permission( async def api_add_role_permission(
role_id: str, role_id: str,
data: CreateRolePermission, data: CreateRolePermission,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Add a permission to a role (admin only)""" """Add a permission to a role (admin only)"""
from . import crud from . import crud
@ -3899,7 +3839,7 @@ async def api_add_role_permission(
async def api_delete_role_permission( async def api_delete_role_permission(
role_id: str, role_id: str,
permission_id: str, permission_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Remove a permission from a role (admin only)""" """Remove a permission from a role (admin only)"""
from . import crud from . import crud
@ -3914,7 +3854,7 @@ async def api_delete_role_permission(
@libra_api_router.post("/api/v1/admin/user-roles", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/admin/user-roles", status_code=HTTPStatus.CREATED)
async def api_assign_user_role( async def api_assign_user_role(
data: AssignUserRole, data: AssignUserRole,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Assign a user to a role (admin only)""" """Assign a user to a role (admin only)"""
from . import crud from . import crud
@ -3928,7 +3868,7 @@ async def api_assign_user_role(
) )
try: try:
user_role = await crud.assign_user_role(data, granted_by=wallet.wallet.user) user_role = await crud.assign_user_role(data, granted_by=auth.user_id)
return { return {
"id": user_role.id, "id": user_role.id,
"user_id": user_role.user_id, "user_id": user_role.user_id,
@ -3949,7 +3889,7 @@ async def api_assign_user_role(
@libra_api_router.get("/api/v1/admin/user-roles/{user_id}") @libra_api_router.get("/api/v1/admin/user-roles/{user_id}")
async def api_get_user_roles( async def api_get_user_roles(
user_id: str, user_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Get all roles assigned to a user (admin only)""" """Get all roles assigned to a user (admin only)"""
from . import crud from . import crud
@ -3982,7 +3922,7 @@ async def api_get_user_roles(
@libra_api_router.delete("/api/v1/admin/user-roles/{user_role_id}") @libra_api_router.delete("/api/v1/admin/user-roles/{user_role_id}")
async def api_revoke_user_role( async def api_revoke_user_role(
user_role_id: str, user_role_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Revoke a user's role assignment (admin only)""" """Revoke a user's role assignment (admin only)"""
from . import crud from . import crud
@ -3993,7 +3933,7 @@ async def api_revoke_user_role(
@libra_api_router.get("/api/v1/admin/users/roles") @libra_api_router.get("/api/v1/admin/users/roles")
async def api_get_all_user_roles( async def api_get_all_user_roles(
wallet: WalletTypeInfo = Depends(require_admin_key), auth: AuthContext = Depends(require_super_user),
): ):
"""Get all user role assignments (admin only)""" """Get all user role assignments (admin only)"""
from . import crud from . import crud