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:
parent
7f9cecefa1
commit
92c1649f3b
4 changed files with 617 additions and 3 deletions
293
views_api.py
293
views_api.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue