From 79849f5fb20500e437234692145359005ea5dd1b Mon Sep 17 00:00:00 2001 From: padreug Date: Tue, 11 Nov 2025 02:41:05 +0100 Subject: [PATCH] Add virtual parent accounts for permission inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements metadata-only accounts (e.g., "Expenses", "Assets") that exist solely in Castle DB for hierarchical permission management. These accounts don't exist in Beancount but cascade permissions to all child accounts. Changes: **Migration (m003)**: - Add `is_virtual` BOOLEAN field to accounts table - Create index idx_accounts_is_virtual - Insert 5 default virtual parents: Assets, Liabilities, Equity, Income, Expenses **Models**: - Add `is_virtual: bool = False` to Account, CreateAccount, AccountWithPermissions **CRUD**: - Update create_account() to pass is_virtual to Account constructor **Account Sync**: - Skip deactivating virtual accounts (they're intentionally metadata-only) - Virtual accounts never get marked as inactive by sync **Use Case**: Admin grants permission on virtual "Expenses" account → user automatically gets access to ALL real expense accounts: - Expenses:Groceries - Expenses:Gas:Kitchen - Expenses:Maintenance:Property - (and all other Expenses:* children) This solves the limitation where Beancount doesn't allow single-level accounts (e.g., bare "Expenses" can't exist in ledger), but admins need a way to grant broad access without manually selecting dozens of accounts. Hierarchical permission inheritance already works via account_name.startswith() check - virtual accounts simply provide the parent nodes to grant permissions on. 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude --- account_sync.py | 5 +++++ crud.py | 1 + migrations.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ models.py | 3 +++ 4 files changed, 64 insertions(+) diff --git a/account_sync.py b/account_sync.py index 9591929..af97525 100644 --- a/account_sync.py +++ b/account_sync.py @@ -199,7 +199,12 @@ async def sync_accounts_from_beancount(force_full_sync: bool = False) -> dict: stats["errors"].append(error_msg) # Step 2: Mark orphaned accounts (in Castle DB but not in Beancount) as inactive + # SKIP virtual accounts (they're intentionally metadata-only) for castle_account in castle_accounts: + if castle_account.is_virtual: + # Virtual accounts are metadata-only, never deactivate them + continue + if castle_account.name not in beancount_account_names: # Account no longer exists in Beancount if castle_account.is_active: diff --git a/crud.py b/crud.py index 7423c3c..fbb9317 100644 --- a/crud.py +++ b/crud.py @@ -65,6 +65,7 @@ async def create_account(data: CreateAccount) -> Account: account_type=data.account_type, description=data.description, user_id=data.user_id, + is_virtual=data.is_virtual, created_at=datetime.now(), ) await db.insert("accounts", account) diff --git a/migrations.py b/migrations.py index c36a4aa..c5dd4f3 100644 --- a/migrations.py +++ b/migrations.py @@ -352,3 +352,58 @@ async def m002_add_account_is_active(db): CREATE INDEX idx_accounts_is_active ON accounts (is_active) """ ) + + +async def m003_add_account_is_virtual(db): + """ + Add is_virtual field to accounts table for virtual parent accounts. + + Virtual parent accounts: + - Exist only in Castle DB (metadata-only, not in Beancount) + - Used solely for permission inheritance + - Allow granting permissions on top-level accounts like "Expenses", "Assets" + - Are not synced to/from Beancount + - Cannot be deactivated by account sync (they're intentionally metadata-only) + + Use case: Grant permission on "Expenses" → user gets access to all Expenses:* children + + Default: All existing accounts are real (is_virtual = FALSE). + """ + await db.execute( + """ + ALTER TABLE accounts + ADD COLUMN is_virtual BOOLEAN NOT NULL DEFAULT FALSE + """ + ) + + # Create index for faster queries filtering by is_virtual + await db.execute( + """ + CREATE INDEX idx_accounts_is_virtual ON accounts (is_virtual) + """ + ) + + # Insert default virtual parent accounts for permission management + import uuid + + virtual_parents = [ + ("Assets", "asset", "All asset accounts"), + ("Liabilities", "liability", "All liability accounts"), + ("Equity", "equity", "All equity accounts"), + ("Income", "revenue", "All income accounts"), + ("Expenses", "expense", "All expense accounts"), + ] + + for name, account_type, description in virtual_parents: + await db.execute( + f""" + INSERT INTO accounts (id, name, account_type, description, is_active, is_virtual, created_at) + VALUES (:id, :name, :type, :description, TRUE, TRUE, {db.timestamp_now}) + """, + { + "id": str(uuid.uuid4()), + "name": name, + "type": account_type, + "description": description, + }, + ) diff --git a/models.py b/models.py index 3507988..83bad04 100644 --- a/models.py +++ b/models.py @@ -37,6 +37,7 @@ class Account(BaseModel): user_id: Optional[str] = None # For user-specific accounts created_at: datetime is_active: bool = True # Soft delete flag + is_virtual: bool = False # Virtual parent account (metadata-only, not in Beancount) class CreateAccount(BaseModel): @@ -44,6 +45,7 @@ class CreateAccount(BaseModel): account_type: AccountType description: Optional[str] = None user_id: Optional[str] = None + is_virtual: bool = False # Set to True to create virtual parent account class EntryLine(BaseModel): @@ -342,6 +344,7 @@ class AccountWithPermissions(BaseModel): user_id: Optional[str] = None created_at: datetime is_active: bool = True # Soft delete flag + is_virtual: bool = False # Virtual parent account (metadata-only) # Only included when filter_by_user=true user_permissions: Optional[list[PermissionType]] = None inherited_from: Optional[str] = None # Parent account ID if inherited