Adds account permissioning system

Adds an account permissioning system to allow granular control over account access.

Introduces the ability to grant users specific permissions (read, submit_expense, manage) on individual accounts.  This includes support for hierarchical permission inheritance, where permissions on parent accounts cascade to child accounts.

Adds new API endpoints for managing account permissions, including granting, listing, and revoking permissions.

Integrates permission checks into existing endpoints, such as creating expense entries, to ensure that users only have access to the accounts they are authorized to use.

Fixes #33 - Implements role based access control
This commit is contained in:
padreug 2025-11-07 17:55:59 +01:00
parent 7f9cecefa1
commit 92c1649f3b
4 changed files with 617 additions and 3 deletions

View file

@ -16,14 +16,18 @@ from .crud import (
approve_manual_payment_request,
check_balance_assertion,
create_account,
create_account_permission,
create_balance_assertion,
create_journal_entry,
create_manual_payment_request,
db,
delete_account_permission,
delete_balance_assertion,
get_account,
get_account_balance,
get_account_by_name,
get_account_permission,
get_account_permissions,
get_account_transactions,
get_all_accounts,
get_all_journal_entries,
@ -38,15 +42,20 @@ from .crud import (
get_or_create_user_account,
get_user_balance,
get_user_manual_payment_requests,
get_user_permissions,
get_user_permissions_with_inheritance,
reject_manual_payment_request,
)
from .models import (
Account,
AccountPermission,
AccountType,
AccountWithPermissions,
AssertionStatus,
BalanceAssertion,
CastleSettings,
CreateAccount,
CreateAccountPermission,
CreateBalanceAssertion,
CreateEntryLine,
CreateJournalEntry,
@ -57,6 +66,7 @@ from .models import (
JournalEntryFlag,
ManualPaymentRequest,
PayUser,
PermissionType,
ReceivableEntry,
RecordPayment,
RevenueEntry,
@ -120,9 +130,84 @@ async def api_get_currencies() -> list[str]:
@castle_api_router.get("/api/v1/accounts")
async def api_get_accounts() -> list[Account]:
"""Get all accounts in the chart of accounts"""
return await get_all_accounts()
async def api_get_accounts(
filter_by_user: bool = False,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[Account] | list[AccountWithPermissions]:
"""
Get all accounts in the chart of accounts.
- filter_by_user: If true, only return accounts the user has permissions for
- Returns AccountWithPermissions objects when filter_by_user=true, otherwise Account objects
"""
all_accounts = await get_all_accounts()
if not filter_by_user:
# Return all accounts without filtering
return all_accounts
# Filter by user permissions
user_id = wallet.wallet.user
user_permissions = await get_user_permissions(user_id)
# Get set of account IDs the user has any permission on
permitted_account_ids = {perm.account_id for perm in user_permissions}
# Build list of accounts with permission metadata
accounts_with_permissions = []
for account in all_accounts:
# Check if user has direct permission on this account
account_perms = [
perm for perm in user_permissions if perm.account_id == account.id
]
# Check if user has inherited permission from parent account
inherited_perms = await get_user_permissions_with_inheritance(
user_id, account.name, PermissionType.READ
)
# Determine if account should be included
has_access = bool(account_perms) or bool(inherited_perms)
if has_access:
# Parse hierarchical account name to get parent and level
parts = account.name.split(":")
level = len(parts) - 1
parent_account = ":".join(parts[:-1]) if level > 0 else None
# Determine inherited_from (which parent account gave access)
inherited_from = None
if inherited_perms and not account_perms:
# Permission is inherited, use the parent account name
_, parent_name = inherited_perms[0]
inherited_from = parent_name
# Collect permission types for this account
permission_types = [perm.permission_type for perm in account_perms]
# Check if account has children
has_children = any(
a.name.startswith(account.name + ":") for a in all_accounts
)
accounts_with_permissions.append(
AccountWithPermissions(
id=account.id,
name=account.name,
account_type=account.account_type,
description=account.description,
user_id=account.user_id,
created_at=account.created_at,
user_permissions=permission_types if permission_types else None,
inherited_from=inherited_from,
parent_account=parent_account,
level=level,
has_children=has_children,
)
)
return accounts_with_permissions
@castle_api_router.post("/api/v1/accounts", status_code=HTTPStatus.CREATED)
@ -285,6 +370,19 @@ async def api_create_expense_entry(
detail=f"Expense account '{data.expense_account}' not found",
)
# Validate user has permission to submit expenses to this account
from .crud import get_user_permissions_with_inheritance
submit_perms = await get_user_permissions_with_inheritance(
wallet.wallet.user, expense_account.name, PermissionType.SUBMIT_EXPENSE
)
if not submit_perms:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail=f"You do not have permission to submit expenses to account '{expense_account.name}'. Please contact an administrator to request access.",
)
# Get or create user-specific account
if data.is_equity:
# Validate equity eligibility
@ -1898,3 +1996,192 @@ async def api_list_equity_eligible_users(
from .crud import get_all_equity_eligible_users
return await get_all_equity_eligible_users()
# ===== ACCOUNT PERMISSION ADMIN ENDPOINTS =====
@castle_api_router.post("/api/v1/admin/permissions", status_code=HTTPStatus.CREATED)
async def api_grant_permission(
data: CreateAccountPermission,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> AccountPermission:
"""Grant account permission to a user (admin only)"""
# Validate that account exists
account = await get_account(data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account with ID '{data.account_id}' not found",
)
return await create_account_permission(data, wallet.wallet.user)
@castle_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),
) -> list[AccountPermission]:
"""
List account permissions (admin only).
Can filter by user_id or account_id.
"""
if user_id:
return await get_user_permissions(user_id)
elif account_id:
return await get_account_permissions(account_id)
else:
# Get all permissions (get all users' permissions)
# This is a bit inefficient but works for admin overview
all_accounts = await get_all_accounts()
all_permissions = []
for account in all_accounts:
account_perms = await get_account_permissions(account.id)
all_permissions.extend(account_perms)
# Deduplicate by permission ID
seen_ids = set()
unique_permissions = []
for perm in all_permissions:
if perm.id not in seen_ids:
seen_ids.add(perm.id)
unique_permissions.append(perm)
return unique_permissions
@castle_api_router.delete("/api/v1/admin/permissions/{permission_id}")
async def api_revoke_permission(
permission_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> dict:
"""Revoke (delete) an account permission (admin only)"""
# Verify permission exists
permission = await get_account_permission(permission_id)
if not permission:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Permission with ID '{permission_id}' not found",
)
await delete_account_permission(permission_id)
return {
"success": True,
"message": f"Permission {permission_id} revoked successfully",
}
@castle_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),
) -> list[AccountPermission]:
"""Grant multiple account permissions at once (admin only)"""
created_permissions = []
for perm_data in permissions:
# Validate that account exists
account = await get_account(perm_data.account_id)
if not account:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Account with ID '{perm_data.account_id}' not found",
)
perm = await create_account_permission(perm_data, wallet.wallet.user)
created_permissions.append(perm)
return created_permissions
# ===== USER PERMISSION ENDPOINTS =====
@castle_api_router.get("/api/v1/users/me/permissions")
async def api_get_user_permissions(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[AccountPermission]:
"""Get current user's account permissions"""
return await get_user_permissions(wallet.wallet.user)
# ===== ACCOUNT HIERARCHY ENDPOINT =====
@castle_api_router.get("/api/v1/accounts/hierarchy")
async def api_get_account_hierarchy(
root_account: str | None = None,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> list[AccountWithPermissions]:
"""
Get hierarchical account structure with user permissions.
Optionally filter by root account (e.g., "Expenses" to get all expense sub-accounts).
"""
all_accounts = await get_all_accounts()
user_id = wallet.wallet.user
user_permissions = await get_user_permissions(user_id)
# Filter by root account if specified
if root_account:
all_accounts = [
acc for acc in all_accounts
if acc.name == root_account or acc.name.startswith(root_account + ":")
]
# Build hierarchy with permission metadata
accounts_with_hierarchy = []
for account in all_accounts:
# Check if user has direct permission on this account
account_perms = [
perm for perm in user_permissions if perm.account_id == account.id
]
# Check if user has inherited permission from parent account
inherited_perms = await get_user_permissions_with_inheritance(
user_id, account.name, PermissionType.READ
)
# Parse hierarchical account name to get parent and level
parts = account.name.split(":")
level = len(parts) - 1
parent_account = ":".join(parts[:-1]) if level > 0 else None
# Determine inherited_from (which parent account gave access)
inherited_from = None
if inherited_perms and not account_perms:
# Permission is inherited, use the parent account name
_, parent_name = inherited_perms[0]
inherited_from = parent_name
# Collect permission types for this account
permission_types = [perm.permission_type for perm in account_perms]
# Check if account has children
has_children = any(
a.name.startswith(account.name + ":") for a in all_accounts
)
accounts_with_hierarchy.append(
AccountWithPermissions(
id=account.id,
name=account.name,
account_type=account.account_type,
description=account.description,
user_id=account.user_id,
created_at=account.created_at,
user_permissions=permission_types if permission_types else None,
inherited_from=inherited_from,
parent_account=parent_account,
level=level,
has_children=has_children,
)
)
# Sort by hierarchical name for natural ordering
accounts_with_hierarchy.sort(key=lambda a: a.name)
return accounts_with_hierarchy