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 (
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