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
Familyrecord acts as an isolation boundary for household financial data. - User membership:
Userrecords belong to one family viabelongs_to :family, with role-based permissions (member, admin, super_admin). - Thread-local scoping: The
Currentattributes class maintainsCurrent.userandCurrent.familythroughout the request lifecycle. - Automatic isolation: All queries use
Current.familyto ensure data never leaks between households. - Invitation workflow: New users join families through
Invitationrecords 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →