Add virtual parent accounts for permission inheritance

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 <noreply@anthropic.com>
This commit is contained in:
padreug 2025-11-11 02:41:05 +01:00
parent 217fee6664
commit 79849f5fb2
4 changed files with 64 additions and 0 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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,
},
)

View file

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