How the Family/Family Member System Enables Multi-User Support in Maybe Finance

Maybe Finance implements multi-user support through a single-tenant "family" model where a Family record acts as the isolation boundary for household data, while individual User records belong to one family and the Current thread-local object maintains request-scoped access control.

The Maybe Finance platform is architected around families rather than individual users to support collaborative household financial management. This family-centric design allows multiple members to share accounts and budgets while ensuring strict data segregation between different households. Understanding how the family and family member system works reveals the robust access control patterns that keep financial data secure in this open-source Rails application.

Core Data Model

The multi-user architecture rests on three interconnected models that establish ownership and access patterns throughout the application.

The Family Model

The Family model (app/models/family.rb) serves as the root tenant container. It owns all household financial data through has_many associations:


# app/models/family.rb

has_many :users, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :transactions, through: :accounts

This design means that deleting a family cascades to all associated records, making it the central isolation boundary.

The User Model

The User model (app/models/user.rb) represents individual family members who log into the application. Each user belongs to exactly one family:


# app/models/user.rb

belongs_to :family
enum role: { member: "member", admin: "admin", super_admin: "super_admin" }

Users carry a role enum that determines their permissions within the household. The model provides helper methods to check privileges:


# app/models/user.rb#L23-L27

def admin?
  super_admin? || role == "admin"
end

The Current Attributes Pattern

Maybe Finance uses Rails' ActiveSupport::CurrentAttributes to maintain thread-safe access to the authenticated context:


# app/models/current.rb

class Current < ActiveSupport::CurrentAttributes
  attribute :user, :family
end

During each request, Current.user holds the authenticated user and Current.family holds their associated family. This pattern appears throughout controllers and services to ensure all data access is properly scoped.

Role-Based Access Control

The application implements three distinct permission levels through the role enum defined in app/models/user.rb:

  • member: Standard access to read and create data within the family
  • admin: Can invite users, export data, and manage family settings
  • super_admin: Full system access including administrative functions

Controllers check these roles using the admin? helper. For example, in FamilyExportsController and Settings::ProfilesController, privileged actions verify Current.user.admin? before proceeding.

Request Scoping and Data Isolation

All domain-level queries automatically scope to the current family through Current.family. This pattern ensures that users can never access data belonging to other households.

In app/controllers/transactions_controller.rb#L8-L14, the controller loads data exclusively for the authenticated user's family:


# app/controllers/transactions_controller.rb#L8-L14

@income_categories = Current.family.categories.incomes.alphabetically
@search = Transaction::Search.new(Current.family, filters: @q)

Because Current.family derives from Current.user, the authentication layer in app/controllers/concerns/authentication.rb#L51-L62 sets both values after successful login, establishing the security context for the entire request lifecycle.

Multi-User Workflows

Inviting New Members

Admins can invite additional family members through the InvitationsController. The invitation record belongs to the family, ensuring that accepted invites automatically associate new users with the correct household:


# In a controller action (admin only)

def invite_member
  invitation = Current.family.invitations.create!(
    email: params[:email],
    inviter: Current.user
  )
  InvitationMailer.with(invite: invitation).invite_email.deliver_later
end

Source: app/controllers/invitations_controller.rb#L14-L15

Authentication Flow

When a user logs in, the authentication concern sets the thread-local context:


# app/controllers/concerns/authentication.rb#L51-L62

def set_current_user
  Current.user = user
  Current.family = user.family
end

This ensures that every subsequent database query, background job enqueue, or service object instantiation has access to the correct tenant context.

Account Deactivation and Cleanup

Maybe Finance implements safeguards to prevent orphaned families or data loss when users leave. The User#deactivate method in app/models/user.rb#L65-L70 validates that an admin cannot deactivate their account if they are the last admin in a family with other members:


# app/models/user.rb#L65-L70

def can_deactivate
  errors.add(:base, :cannot_deactivate_admin_with_other_users) if admin? && family.users.count > 1
end

If the user is the last member of the family, the deactivation process also destroys the entire Family record and all associated data, ensuring clean data removal.

Practical Implementation Examples

Scoping Queries to the Current Family

Access family-specific data through Current.family in controllers or services:


# List all budgets for the signed-in user's family

budgets = Current.family.budgets.order(:name)

Source: app/models/family.rb#L33-L34

Enforcing Admin Privileges

Restrict destructive actions to administrators:

def destroy
  unless Current.user.admin?
    redirect_to root_path, alert: "Only admins can delete families."
    return
  end

  Current.family.destroy
  redirect_to root_path, notice: "Family deleted."
end

Source: app/models/user.rb#L65-L67

Background Job Isolation

When enqueueing background jobs, pass the family ID to maintain scoping outside the request cycle:

class FamilyDataExportJob < ApplicationJob
  def perform(family_id)
    family = Family.find(family_id)
    # Export logic scoped to this family only

    Family::DataExporter.new(family).export
  end
end

The controller enqueues the job with Current.family.id, ensuring the export contains only the requesting household's data.

Source: app/controllers/family_exports_controller.rb#L12-L24

Summary

  • Single-tenant architecture: Each Family record acts as an isolation boundary for household financial data.
  • User membership: User records belong to one family via belongs_to :family, with role-based permissions (member, admin, super_admin).
  • Thread-local scoping: The Current attributes class maintains Current.user and Current.family throughout the request lifecycle.
  • Automatic isolation: All queries use Current.family to ensure data never leaks between households.
  • Invitation workflow: New users join families through Invitation records that preserve the family association.
  • Safe cleanup: Deactivation logic prevents removing the last admin without transferring ownership and cleans up empty families.

Frequently Asked Questions

What is the difference between a Family and a User in Maybe Finance?

A Family is the root tenant container that owns all household data including accounts, transactions, and budgets. A User represents an individual login account that belongs to exactly one family. While multiple users can belong to the same family, each user can only access data within their assigned family.

How does Maybe Finance ensure data isolation between different households?

The application uses the Current thread-local object to store the authenticated user and their family during each request. All database queries scope to Current.family, as seen in controllers like TransactionsController, ensuring that SQL queries filter records by the user's family ID. This architectural pattern prevents cross-family data leakage by design.

What roles are available for family members?

Maybe Finance defines three roles in the User model enum: member (standard read/write access), admin (can invite users, export data, and manage settings), and super_admin (full system privileges). The admin? method returns true for both admins and super_admins, while specific super-admin checks use super_admin?.

How does the invitation system work for adding new family members?

Admins create Invitation records associated with Current.family. When an invitee accepts through the sign-up link, the application creates a new User record automatically associated with invitation.family. This workflow ensures new members join the correct household without manual family ID assignment.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →