From 1201557f0c8db6f1534f6032ea1b8377ea351247 Mon Sep 17 00:00:00 2001 From: Padreug Date: Fri, 5 Jun 2026 22:57:59 +0200 Subject: [PATCH] Gate cross-user admin endpoints behind require_super_user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- views_api.py | 134 ++++++++++++++------------------------------------- 1 file changed, 37 insertions(+), 97 deletions(-) diff --git a/views_api.py b/views_api.py index 3e139d6..d31f881 100644 --- a/views_api.py +++ b/views_api.py @@ -9,7 +9,6 @@ from lnbits.core.models import User, WalletTypeInfo from lnbits.decorators import ( check_super_user, check_user_exists, - require_admin_key, require_invoice_key, ) 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) async def api_create_receivable_entry( data: ReceivableEntry, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> JournalEntry: """ 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 description=data.description + description_suffix, entry_date=datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_by=auth.user_id, created_at=datetime.now(), reference=libra_reference, # Use libra reference with unique ID 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) async def api_create_revenue_entry( data: RevenueEntry, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> JournalEntry: """ Create a revenue entry (libra receives payment). @@ -1547,7 +1546,7 @@ async def api_create_revenue_entry( id=entry_id, description=data.description, entry_date=datetime.now(), - created_by=wallet.wallet.user, # Use user_id, not wallet_id + created_by=auth.user_id, created_at=datetime.now(), reference=libra_reference, 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") async def api_settle_receivable( data: SettleReceivable, @@ -2119,7 +2059,7 @@ async def api_settle_receivable( if "meta" not in entry: entry["meta"] = {} entry["meta"]["payment-method"] = data.payment_method - entry["meta"]["settled-by"] = wallet.wallet.user + entry["meta"]["settled-by"] = auth.user_id if data.txid: entry["meta"]["txid"] = data.txid @@ -2493,7 +2433,7 @@ async def api_expense_report( @libra_api_router.get("/api/v1/reports/contributions") async def api_contributions_report( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ Get user contribution report using BQL. @@ -2562,7 +2502,7 @@ async def api_contributions_report( async def api_get_unsettled_entries( user_id: str, entry_type: str = "expense", - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ 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 entry_id = f"fava-{datetime.now().timestamp()}" 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) async def api_grant_equity_eligibility( data: CreateUserEquityStatus, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> UserEquityStatus: """Grant equity contribution eligibility to a user (admin only)""" 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}") async def api_revoke_equity_eligibility( user_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> UserEquityStatus: """Revoke equity contribution eligibility from a user (admin only)""" 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") async def api_list_equity_eligible_users( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[UserEquityStatus]: """List all equity-eligible users (admin only)""" 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) async def api_grant_permission( data: CreateAccountPermission, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> AccountPermission: """Grant account permission to a user (admin only)""" # Validate that account exists @@ -3396,14 +3336,14 @@ async def api_grant_permission( 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") async def api_list_permissions( user_id: str | None = None, account_id: str | None = None, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[AccountPermission]: """ List account permissions (admin only). @@ -3436,7 +3376,7 @@ async def api_list_permissions( @libra_api_router.delete("/api/v1/admin/permissions/{permission_id}") async def api_revoke_permission( permission_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """Revoke (delete) an account permission (admin only)""" # 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) async def api_bulk_grant_permissions( permissions: list[CreateAccountPermission], - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list[AccountPermission]: """Grant multiple account permissions at once (admin only)""" created_permissions = [] @@ -3472,7 +3412,7 @@ async def api_bulk_grant_permissions( 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) 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) async def api_bulk_grant_permission_to_users( data: "BulkGrantPermission", - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> "BulkGrantResult": """ 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, 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) except Exception as e: failed.append({ @@ -3628,7 +3568,7 @@ async def api_get_account_hierarchy( @libra_api_router.post("/api/v1/admin/accounts/sync") async def api_sync_all_accounts( force_full_sync: bool = False, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ 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 - 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: 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}") async def api_sync_single_account( account_name: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> dict: """ 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 - 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: 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") async def api_get_all_roles( - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ) -> list: """Get all roles (admin only)""" 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) async def api_create_role( data: CreateRole, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Create a new role (admin only)""" from . import crud try: - role = await crud.create_role(data, created_by=wallet.wallet.user) + role = await crud.create_role(data, created_by=auth.user_id) return { "id": role.id, "name": role.name, @@ -3763,7 +3703,7 @@ async def api_create_role( @libra_api_router.get("/api/v1/admin/roles/{role_id}") async def api_get_role( 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)""" from . import crud @@ -3813,7 +3753,7 @@ async def api_get_role( async def api_update_role( role_id: str, data: UpdateRole, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Update a role (admin only)""" from . import crud @@ -3838,7 +3778,7 @@ async def api_update_role( @libra_api_router.delete("/api/v1/admin/roles/{role_id}") async def api_delete_role( 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""" from . import crud @@ -3861,7 +3801,7 @@ async def api_delete_role( async def api_add_role_permission( role_id: str, data: CreateRolePermission, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Add a permission to a role (admin only)""" from . import crud @@ -3899,7 +3839,7 @@ async def api_add_role_permission( async def api_delete_role_permission( role_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)""" 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) async def api_assign_user_role( data: AssignUserRole, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Assign a user to a role (admin only)""" from . import crud @@ -3928,7 +3868,7 @@ async def api_assign_user_role( ) 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 { "id": user_role.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}") async def api_get_user_roles( user_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Get all roles assigned to a user (admin only)""" 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}") async def api_revoke_user_role( user_role_id: str, - wallet: WalletTypeInfo = Depends(require_admin_key), + auth: AuthContext = Depends(require_super_user), ): """Revoke a user's role assignment (admin only)""" from . import crud @@ -3993,7 +3933,7 @@ async def api_revoke_user_role( @libra_api_router.get("/api/v1/admin/users/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)""" from . import crud