Add centralized authorization module and fix security vulnerabilities

- Create auth.py with AuthContext, require_super_user, require_authenticated
- Fix 6 CRITICAL unprotected endpoints exposing sensitive data
- Consolidate 16+ admin endpoints with duplicated super_user checks
- Standardize on user_id (wallet.wallet.user) instead of wallet_id

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
padreug 2026-01-07 13:35:07 +01:00
parent e403ec223d
commit ca0cee7312
2 changed files with 412 additions and 191 deletions

View file

@ -83,6 +83,14 @@ from .models import (
UserWithRoles,
)
from .services import get_settings, get_user_wallet, update_settings, update_user_wallet
from .auth import (
AuthContext,
require_authenticated,
require_authenticated_write,
require_super_user,
require_account_access,
require_user_data_access,
)
castle_api_router = APIRouter()
@ -276,26 +284,34 @@ async def api_get_accounts(
@castle_api_router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED)
async def api_create_account(
data: CreateAccount,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> Account:
"""Create a new account (admin only)"""
"""Create a new account (super user only)"""
return await create_account(data)
@castle_api_router.get("/api/v1/accounts/{account_id}")
async def api_get_account(account_id: str) -> Account:
"""Get a specific account"""
async def api_get_account(
account_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> Account:
"""Get a specific account (requires authentication and account access)"""
account = await get_account(account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Account not found"
)
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
return account
@castle_api_router.get("/api/v1/accounts/{account_id}/balance")
async def api_get_account_balance(account_id: str) -> dict:
"""Get account balance from Fava/Beancount"""
async def api_get_account_balance(
account_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> dict:
"""Get account balance from Fava/Beancount (requires authentication and account access)"""
from .fava_client import get_fava_client
# Get account to retrieve its name
@ -303,6 +319,9 @@ async def api_get_account_balance(account_id: str) -> dict:
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
# Query Fava for balance
fava = get_fava_client()
balance_data = await fava.get_account_balance(account.name)
@ -316,11 +335,16 @@ async def api_get_account_balance(account_id: str) -> dict:
@castle_api_router.get("/api/v1/accounts/{account_id}/transactions")
async def api_get_account_transactions(account_id: str, limit: int = 100) -> list[dict]:
async def api_get_account_transactions(
account_id: str,
limit: int = 100,
auth: AuthContext = Depends(require_authenticated),
) -> list[dict]:
"""
Get all transactions for an account from Fava/Beancount.
Returns transactions affecting this account in reverse chronological order.
Requires authentication and account access.
"""
from .fava_client import get_fava_client
@ -332,6 +356,9 @@ async def api_get_account_transactions(account_id: str, limit: int = 100) -> lis
detail=f"Account {account_id} not found"
)
# Check access permission
await require_account_access(auth, account_id, PermissionType.READ)
# Query Fava for transactions
fava = get_fava_client()
transactions = await fava.get_account_transactions(account.name, limit)
@ -343,11 +370,15 @@ async def api_get_account_transactions(account_id: str, limit: int = 100) -> lis
@castle_api_router.get("/api/v1/entries")
async def api_get_journal_entries(limit: int = 100) -> list[dict]:
async def api_get_journal_entries(
limit: int = 100,
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""
Get all journal entries from Fava/Beancount.
Returns all transactions in reverse chronological order with username enrichment.
SUPER USER ONLY - exposes all transaction data.
"""
from lnbits.core.crud.users import get_user
from .fava_client import get_fava_client
@ -721,22 +752,15 @@ async def _get_username_from_user_id(user_id: str) -> str:
@castle_api_router.get("/api/v1/entries/pending")
async def api_get_pending_entries(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""
Get all pending expense entries that need approval (admin only).
Get all pending expense entries that need approval (super user only).
Returns transactions with flag='!' from Fava/Beancount.
"""
from lnbits.settings import settings as lnbits_settings
from .fava_client import get_fava_client
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access this endpoint",
)
# Query Fava for all journal entries (includes links, tags, full metadata)
fava = get_fava_client()
all_entries = await fava.get_journal_entries()
@ -949,7 +973,7 @@ async def api_create_journal_entry(
# Entry metadata (excluding tags and links which go at transaction level)
entry_meta = {k: v for k, v in data.meta.items() if k not in ["tags", "links"]}
entry_meta["source"] = "castle-api"
entry_meta["created-by"] = wallet.wallet.id
entry_meta["created-by"] = wallet.wallet.user # Use user_id, not wallet_id
# Format as Beancount entry
fava = get_fava_client()
@ -975,7 +999,7 @@ async def api_create_journal_entry(
id=f"fava-{timestamp}",
description=data.description,
entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.id,
created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_at=datetime.now(),
reference=data.reference,
flag=data.flag if data.flag else JournalEntryFlag.CLEARED,
@ -1138,7 +1162,7 @@ async def api_create_expense_entry(
id=entry_id, # Use the generated castle entry ID
description=data.description + description_suffix,
entry_date=data.entry_date if data.entry_date else datetime.now(),
created_by=wallet.wallet.id,
created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_at=datetime.now(),
reference=castle_reference,
flag=JournalEntryFlag.PENDING,
@ -1266,7 +1290,7 @@ async def api_create_receivable_entry(
id=entry_id, # Use the generated castle entry ID
description=data.description + description_suffix,
entry_date=datetime.now(),
created_by=wallet.wallet.id,
created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_at=datetime.now(),
reference=castle_reference, # Use castle reference with unique ID
flag=JournalEntryFlag.PENDING,
@ -1380,7 +1404,7 @@ async def api_create_revenue_entry(
id=entry_id,
description=data.description,
entry_date=datetime.now(),
created_by=wallet.wallet.id,
created_by=wallet.wallet.user, # Use user_id, not wallet_id
created_at=datetime.now(),
reference=castle_reference,
flag=JournalEntryFlag.CLEARED,
@ -1444,8 +1468,18 @@ async def api_get_my_balance(
@castle_api_router.get("/api/v1/balance/{user_id}")
async def api_get_user_balance(user_id: str) -> UserBalance:
"""Get a specific user's balance with the Castle (from Fava/Beancount)"""
async def api_get_user_balance(
user_id: str,
auth: AuthContext = Depends(require_authenticated),
) -> UserBalance:
"""
Get a specific user's balance with the Castle (from Fava/Beancount).
Users can only access their own balance. Super users can access any user's balance.
"""
# Check access: must be own data or super user
await require_user_data_access(auth, user_id)
from .fava_client import get_fava_client
fava = get_fava_client()
@ -1461,9 +1495,9 @@ async def api_get_user_balance(user_id: str) -> UserBalance:
@castle_api_router.get("/api/v1/balances/all")
async def api_get_all_balances(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""Get all user balances (admin/super user only) from Fava/Beancount"""
"""Get all user balances (super user only) from Fava/Beancount"""
from .fava_client import get_fava_client
fava = get_fava_client()
@ -1802,7 +1836,7 @@ async def api_pay_user(
@castle_api_router.post("/api/v1/receivables/settle")
async def api_settle_receivable(
data: SettleReceivable,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Manually settle a receivable (record when user pays castle in person).
@ -1812,15 +1846,8 @@ async def api_settle_receivable(
- Bank transfers
- Other manual settlements
Admin only.
Super user only.
"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can settle receivables",
)
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
@ -1957,7 +1984,7 @@ async def api_settle_receivable(
@castle_api_router.post("/api/v1/payables/pay")
async def api_pay_user(
data: PayUser,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Pay a user (castle pays user for expense/liability).
@ -1966,15 +1993,8 @@ async def api_pay_user(
- Lightning payments: already executed, just record the payment
- Cash/Bank/Check: record manual payment that was made
Admin only.
Super user only.
"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can pay users",
)
# Validate payment method
valid_methods = ["cash", "bank_transfer", "check", "lightning", "btc_onchain", "other"]
@ -2152,16 +2172,9 @@ async def api_update_settings(
@castle_api_router.get("/api/v1/user-wallet/{user_id}")
async def api_get_user_wallet(
user_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get user's wallet settings (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access user wallet info",
)
"""Get user's wallet settings (super user only)"""
user_wallet = await get_user_wallet(user_id)
if not user_wallet:
@ -2183,9 +2196,9 @@ async def api_get_user_wallet(
@castle_api_router.get("/api/v1/users")
async def api_get_all_users(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""Get all users who have configured their wallet (admin only)"""
"""Get all users who have configured their wallet (super user only)"""
from lnbits.core.crud.users import get_user
user_settings = await get_all_user_wallet_settings()
@ -2209,12 +2222,12 @@ async def api_get_all_users(
@castle_api_router.get("/api/v1/admin/castle-users")
async def api_get_castle_users(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list[dict]:
"""
Get all users who have configured their wallet in Castle.
These are users who can interact with Castle (submit expenses, receive permissions, etc.).
Admin only.
Super user only.
"""
from lnbits.core.crud.users import get_user
@ -2247,10 +2260,10 @@ async def api_expense_report(
start_date: Optional[str] = None,
end_date: Optional[str] = None,
group_by: str = "account",
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Get expense summary report using BQL.
Get expense summary report using BQL. Super user only.
Args:
start_date: Filter from this date (YYYY-MM-DD), optional
@ -2511,32 +2524,18 @@ async def api_get_manual_payment_requests(
@castle_api_router.get("/api/v1/manual-payment-requests/all")
async def api_get_all_manual_payment_requests(
status: str = None,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list[ManualPaymentRequest]:
"""Get all manual payment requests (Castle admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access this endpoint",
)
"""Get all manual payment requests (super user only)"""
return await get_all_manual_payment_requests(status)
@castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/approve")
async def api_approve_manual_payment_request(
request_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest:
"""Approve a manual payment request and create accounting entry (Castle admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access this endpoint",
)
"""Approve a manual payment request and create accounting entry (super user only)"""
# Get the request
request = await get_manual_payment_request(request_id)
@ -2604,17 +2603,9 @@ async def api_approve_manual_payment_request(
@castle_api_router.post("/api/v1/manual-payment-requests/{request_id}/reject")
async def api_reject_manual_payment_request(
request_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> ManualPaymentRequest:
"""Reject a manual payment request (Castle admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access this endpoint",
)
"""Reject a manual payment request (super user only)"""
# Get the request
request = await get_manual_payment_request(request_id)
if not request:
@ -2629,7 +2620,7 @@ async def api_reject_manual_payment_request(
detail=f"Request already {request.status}",
)
return await reject_manual_payment_request(request_id, wallet.wallet.user)
return await reject_manual_payment_request(request_id, auth.user_id)
# ===== EXPENSE APPROVAL ENDPOINTS =====
@ -2638,22 +2629,15 @@ async def api_reject_manual_payment_request(
@castle_api_router.post("/api/v1/entries/{entry_id}/approve")
async def api_approve_expense_entry(
entry_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Approve a pending expense entry by changing flag from '!' to '*' (admin only).
Approve a pending expense entry by changing flag from '!' to '*' (super user only).
This updates the transaction in the Beancount file via Fava API.
"""
from lnbits.settings import settings as lnbits_settings
from .fava_client import get_fava_client
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can approve expenses",
)
fava = get_fava_client()
# 1. Get all journal entries from Fava
@ -2725,23 +2709,16 @@ async def api_approve_expense_entry(
@castle_api_router.post("/api/v1/entries/{entry_id}/reject")
async def api_reject_expense_entry(
entry_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Reject a pending expense entry by marking it as voided (admin only).
Reject a pending expense entry by marking it as voided (super user only).
Adds #voided tag for audit trail while keeping the '!' flag.
Voided transactions are excluded from balances but preserved in the ledger.
"""
from lnbits.settings import settings as lnbits_settings
from .fava_client import get_fava_client
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can reject expenses",
)
fava = get_fava_client()
# 1. Get all journal entries from Fava
@ -2823,10 +2800,10 @@ async def api_reject_expense_entry(
@castle_api_router.post("/api/v1/assertions")
async def api_create_balance_assertion(
data: CreateBalanceAssertion,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion:
"""
Create a balance assertion for reconciliation (admin only).
Create a balance assertion for reconciliation (super user only).
Uses hybrid approach:
1. Writes balance assertion to Beancount (via Fava) - source of truth
@ -2835,16 +2812,9 @@ async def api_create_balance_assertion(
The assertion will be checked immediately upon creation.
"""
from lnbits.settings import settings as lnbits_settings
from .fava_client import get_fava_client
from .beancount_format import format_balance
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can create balance assertions",
)
# Verify account exists
account = await get_account(data.account_id)
if not account:
@ -2876,7 +2846,7 @@ async def api_create_balance_assertion(
)
# Store metadata in Castle DB for UI convenience
assertion = await create_balance_assertion(data, wallet.wallet.user)
assertion = await create_balance_assertion(data, auth.user_id)
# Check it immediately (queries Fava for actual balance)
try:
@ -2911,16 +2881,9 @@ async def api_get_balance_assertions(
account_id: str = None,
status: str = None,
limit: int = 100,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> list[BalanceAssertion]:
"""Get balance assertions with optional filters (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can view balance assertions",
)
"""Get balance assertions with optional filters (super user only)"""
# Parse status enum if provided
status_enum = None
@ -2943,17 +2906,9 @@ async def api_get_balance_assertions(
@castle_api_router.get("/api/v1/assertions/{assertion_id}")
async def api_get_balance_assertion(
assertion_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion:
"""Get a specific balance assertion (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can view balance assertions",
)
"""Get a specific balance assertion (super user only)"""
assertion = await get_balance_assertion(assertion_id)
if not assertion:
raise HTTPException(
@ -2967,17 +2922,9 @@ async def api_get_balance_assertion(
@castle_api_router.post("/api/v1/assertions/{assertion_id}/check")
async def api_check_balance_assertion(
assertion_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> BalanceAssertion:
"""Re-check a balance assertion (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can check balance assertions",
)
"""Re-check a balance assertion (super user only)"""
try:
assertion = await check_balance_assertion(assertion_id)
except ValueError as e:
@ -2992,17 +2939,9 @@ async def api_check_balance_assertion(
@castle_api_router.delete("/api/v1/assertions/{assertion_id}")
async def api_delete_balance_assertion(
assertion_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Delete a balance assertion (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can delete balance assertions",
)
"""Delete a balance assertion (super user only)"""
# Verify it exists
assertion = await get_balance_assertion(assertion_id)
if not assertion:
@ -3021,16 +2960,9 @@ async def api_delete_balance_assertion(
@castle_api_router.get("/api/v1/reconciliation/summary")
async def api_get_reconciliation_summary(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get reconciliation summary (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can access reconciliation",
)
"""Get reconciliation summary (super user only)"""
# Get all assertions
all_assertions = await get_balance_assertions(limit=1000)
@ -3079,16 +3011,9 @@ async def api_get_reconciliation_summary(
@castle_api_router.post("/api/v1/reconciliation/check-all")
async def api_check_all_assertions(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Re-check all balance assertions (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can run reconciliation checks",
)
"""Re-check all balance assertions (super user only)"""
# Get all assertions
all_assertions = await get_balance_assertions(limit=1000)
@ -3117,16 +3042,9 @@ async def api_check_all_assertions(
@castle_api_router.get("/api/v1/reconciliation/discrepancies")
async def api_get_discrepancies(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""Get all discrepancies (failed assertions, flagged entries) (admin only)"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can view discrepancies",
)
"""Get all discrepancies (failed assertions, flagged entries) (super user only)"""
# Get failed assertions
failed_assertions = await get_balance_assertions(
@ -3154,21 +3072,14 @@ async def api_get_discrepancies(
@castle_api_router.post("/api/v1/tasks/daily-reconciliation")
async def api_run_daily_reconciliation(
wallet: WalletTypeInfo = Depends(require_admin_key),
auth: AuthContext = Depends(require_super_user),
) -> dict:
"""
Manually trigger the daily reconciliation check (admin only).
Manually trigger the daily reconciliation check (super user only).
This endpoint can also be called via cron job.
Returns a summary of the reconciliation check results.
"""
from lnbits.settings import settings as lnbits_settings
if wallet.wallet.user != lnbits_settings.super_user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Only super user can run daily reconciliation",
)
from .tasks import check_all_balance_assertions