SKILL: Multi-Tenant Resource Patterns
Purpose: Comprehensive guide for implementing organization-based multi-tenancy in Ash resources with proper data isolation
Context: Elixir + Ash Framework 3.0 + PostgreSQL + ElectricSQL + Multi-tenant SaaS architecture
When to Use:
- •Creating any domain resource (except User/Organization)
- •Implementing data isolation between organizations
- •Setting up ElectricSQL sync with tenant filtering
- •Testing organization-scoped queries
- •Implementing authorization policies
Core Principles
1. Organization is the Tenant Boundary
Key Understanding:
- •Every organization owns their own data
- •Users belong to one organization
- •All domain data scoped by
organization_id - •No cross-organization data access (except admins)
Data Model:
code
Organization (id: uuid) ├─→ User (organization_id: uuid) ├─→ Post (organization_id: uuid) ├─→ Comment (organization_id: uuid) └─→ [Your Domain Resource] (organization_id: uuid)
2. organization_id is NOT Updatable
CRITICAL: Once a resource is created with an organization_id, it can NEVER change.
Why:
- •Moving data between organizations creates audit trails issues
- •Can leak data if authorization checks fail
- •Breaks ElectricSQL sync assumptions
- •Violates data isolation guarantees
Implementation:
elixir
create :create do accept [:title, :content, :organization_id] # OK: Set on create end update :update do accept [:title, :content] # NO organization_id - prevent updates end
3. Every Query Must Filter by Organization
Default behavior:
elixir
# ❌ BAD: Returns ALL posts across ALL organizations Post.read() # ✅ GOOD: Returns posts for ONE organization Post.by_organization(org_id)
Common Pitfalls & Solutions
❌ Pitfall 1: Missing organization_id Attribute
WRONG:
elixir
defmodule MyApp.Blog.Post do
use Ash.Resource, ...
attributes do
uuid_primary_key :id
attribute :title, :string
# Missing organization_id!
end
end
Why it fails:
- •Data not isolated between tenants
- •ElectricSQL can't filter by organization
- •Violates usage rules
- •Security vulnerability
✅ Correct Pattern:
elixir
attributes do
uuid_primary_key :id
attribute :title, :string, allow_nil?: false
attribute :organization_id, :uuid, allow_nil?: false # ← REQUIRED
create_timestamp :inserted_at
update_timestamp :updated_at
end
relationships do
belongs_to :organization, MyApp.Auth.Organization do
allow_nil? false
end
end
❌ Pitfall 2: organization_id is Updatable
WRONG:
elixir
update :update do accept [:title, :content, :organization_id] # ← DANGER! end
Why it's dangerous:
elixir
# User from Org A could move data to Org B!
Post.update(post_id, %{organization_id: other_org_id})
# This breaks data isolation
✅ Correct Pattern:
elixir
create :create do accept [:title, :content, :organization_id] # Set once on create end update :update do accept [:title, :content] # NO organization_id end
❌ Pitfall 3: Unscoped Read Actions
WRONG:
elixir
actions do defaults [:read, :create, :update, :destroy] # Default :read has NO organization filter! end
Why it's problematic:
elixir
# Returns ALL posts from ALL organizations
{:ok, posts} = Post.read()
# Security breach!
✅ Correct Pattern:
elixir
actions do
# Keep default :read for admin use (with authorization)
defaults [:read, :destroy]
create :create do
accept [:title, :organization_id]
end
update :update do
accept [:title]
end
# Primary read action: scoped by organization
read :by_organization do
argument :organization_id, :uuid, allow_nil?: false
filter expr(organization_id == ^arg(:organization_id))
# Make this the primary read for non-admins
primary? true
end
# For lists scoped to organization
read :list do
argument :organization_id, :uuid, allow_nil?: false
filter expr(organization_id == ^arg(:organization_id))
pagination offset: true, keyset: true, default_limit: 25
end
end
❌ Pitfall 4: Missing Organization Relationship
WRONG:
elixir
attributes do attribute :organization_id, :uuid, allow_nil?: false end # Missing belongs_to relationship!
Why it's incomplete:
- •Can't load organization data
- •Harder to query across relationships
- •Loses referential integrity benefits
✅ Correct Pattern:
elixir
attributes do
attribute :organization_id, :uuid, allow_nil?: false
end
relationships do
belongs_to :organization, MyApp.Auth.Organization do
allow_nil? false
attribute_writable? true # Can set in create action
end
end
❌ Pitfall 5: Forgetting to Test Organization Isolation
WRONG:
elixir
test "creates post" do
# Only tests that creation works
# Doesn't verify organization isolation
{:ok, post} = create_post()
assert post.title == "Test"
end
✅ Correct Pattern:
elixir
test "organization isolation" do
org1 = create_organization()
org2 = create_organization()
# Create posts in different organizations
post1 = create_post(organization_id: org1.id, title: "Org 1 Post")
post2 = create_post(organization_id: org2.id, title: "Org 2 Post")
# Query org1 - should only see org1's post
{:ok, org1_posts} = Post.by_organization(org1.id)
assert length(org1_posts) == 1
assert hd(org1_posts).id == post1.id
# Query org2 - should only see org2's post
{:ok, org2_posts} = Post.by_organization(org2.id)
assert length(org2_posts) == 1
assert hd(org2_posts).id == post2.id
end
Complete Working Pattern
Step 1: Define Multi-Tenant Resource
elixir
defmodule MyApp.Blog.Post do
@moduledoc """
Blog post resource with organization-based multi-tenancy.
All posts are scoped to a single organization.
"""
use Ash.Resource,
domain: MyApp.Api,
data_layer: AshPostgres.DataLayer
postgres do
table "posts"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :title, :string do
allow_nil? false
constraints min_length: 1, max_length: 255
end
attribute :content, :string do
allow_nil? true
end
# CRITICAL: Organization scoping
attribute :organization_id, :uuid do
allow_nil? false
# Cannot be changed after creation
writable? :create
end
create_timestamp :inserted_at
update_timestamp :updated_at
end
relationships do
# Required relationship to organization
belongs_to :organization, MyApp.Auth.Organization do
allow_nil? false
attribute_writable? true
end
# Posts belong to user (who belongs to organization)
belongs_to :author, MyApp.Auth.User do
allow_nil? false
end
end
actions do
defaults [:destroy]
# Allow default read for admin use only
read :read do
# Add authorization policy
# authorize_if actor_attribute_equals(:is_admin, true)
end
create :create do
accept [:title, :content, :organization_id, :author_id]
# Validate author belongs to same organization
validate fn changeset, _context ->
author_id = Ash.Changeset.get_attribute(changeset, :author_id)
org_id = Ash.Changeset.get_attribute(changeset, :organization_id)
case MyApp.Auth.User.by_id(author_id) do
{:ok, user} ->
if user.organization_id == org_id do
:ok
else
{:error, field: :author_id, message: "must belong to same organization"}
end
_ ->
:ok # Will fail on foreign key constraint
end
end
end
update :update do
# NO organization_id - prevent updates
accept [:title, :content]
end
# PRIMARY read action: by organization
read :by_organization do
argument :organization_id, :uuid, allow_nil?: false
filter expr(organization_id == ^arg(:organization_id))
primary? true
end
# Read with pagination
read :list do
argument :organization_id, :uuid, allow_nil?: false
filter expr(organization_id == ^arg(:organization_id))
pagination offset: true, keyset: true, default_limit: 25
end
# Read single post (still scoped to organization for security)
read :get do
argument :id, :uuid, allow_nil?: false
argument :organization_id, :uuid, allow_nil?: false
get? true
filter expr(
id == ^arg(:id) and
organization_id == ^arg(:organization_id)
)
end
end
code_interface do
define :create
define :update, args: [:id]
define :destroy, args: [:id]
define :by_organization, args: [:organization_id]
define :list, args: [:organization_id]
define :get, args: [:id, :organization_id]
end
end
Step 2: Generate Migration with Indexes
bash
mix ash_postgres.generate_migrations --name add_posts
Review migration (should include organization_id index):
elixir
defmodule MyApp.Repo.Migrations.AddPosts do
use Ecto.Migration
def up do
create table(:posts, primary_key: false) do
add :id, :uuid, null: false, primary_key: true
add :title, :text, null: false
add :content, :text
add :organization_id, :uuid, null: false
add :author_id, :uuid, null: false
add :inserted_at, :utc_datetime_usec, null: false
add :updated_at, :utc_datetime_usec, null: false
end
# CRITICAL: Index on organization_id for fast scoped queries
create index(:posts, [:organization_id])
# Foreign key constraints ensure referential integrity
create constraint(:posts, :fk_organization_id,
foreign_key: [:organization_id],
references: :organizations,
on_delete: :delete_all # or :restrict
)
create constraint(:posts, :fk_author_id,
foreign_key: [:author_id],
references: :users,
on_delete: :delete_all # or :restrict
)
end
def down do
drop table(:posts)
end
end
Step 3: Comprehensive Tests
elixir
defmodule MyApp.Blog.PostTest do
use MyApp.DataCase
alias MyApp.Blog.Post
describe "create/1" do
test "creates post with organization_id" do
org = create_organization()
user = create_user(organization_id: org.id)
assert {:ok, post} =
Post.create(%{
title: "Test Post",
content: "Content",
organization_id: org.id,
author_id: user.id
})
assert post.organization_id == org.id
end
test "requires organization_id" do
user = create_user()
assert {:error, %Ash.Error.Invalid{}} =
Post.create(%{
title: "Test",
author_id: user.id
})
end
test "validates author belongs to same organization" do
org1 = create_organization()
org2 = create_organization()
user = create_user(organization_id: org2.id)
# Try to create post in org1 with author from org2
assert {:error, error} =
Post.create(%{
title: "Test",
organization_id: org1.id,
author_id: user.id
})
assert error.errors
|> Enum.any?(fn e ->
String.contains?(e.message, "must belong to same organization")
end)
end
end
describe "update/1" do
test "updates post attributes but NOT organization_id" do
org = create_organization()
user = create_user(organization_id: org.id)
post = create_post(organization_id: org.id, author_id: user.id)
# Update should work
assert {:ok, updated} = Post.update(post.id, %{title: "Updated"})
assert updated.title == "Updated"
assert updated.organization_id == org.id # Unchanged
# Attempting to update organization_id should be ignored
# (because it's not in the accept list)
end
end
describe "by_organization/1" do
test "returns only posts for specified organization" do
org1 = create_organization()
org2 = create_organization()
user1 = create_user(organization_id: org1.id)
user2 = create_user(organization_id: org2.id)
post1 = create_post(organization_id: org1.id, author_id: user1.id)
post2 = create_post(organization_id: org1.id, author_id: user1.id)
_post3 = create_post(organization_id: org2.id, author_id: user2.id)
{:ok, org1_posts} = Post.by_organization(org1.id)
assert length(org1_posts) == 2
assert Enum.all?(org1_posts, &(&1.organization_id == org1.id))
assert Enum.map(org1_posts, & &1.id) |> Enum.sort() ==
[post1.id, post2.id] |> Enum.sort()
end
test "returns empty list for organization with no posts" do
org = create_organization()
{:ok, posts} = Post.by_organization(org.id)
assert posts == []
end
end
describe "get/2 (organization-scoped single read)" do
test "returns post when organization matches" do
org = create_organization()
user = create_user(organization_id: org.id)
post = create_post(organization_id: org.id, author_id: user.id)
{:ok, found} = Post.get(post.id, org.id)
assert found.id == post.id
end
test "returns error when organization doesn't match" do
org1 = create_organization()
org2 = create_organization()
user = create_user(organization_id: org1.id)
post = create_post(organization_id: org1.id, author_id: user.id)
# Try to get org1's post using org2's id
assert {:error, %Ash.Error.Query.NotFound{}} =
Post.get(post.id, org2.id)
end
end
describe "organization isolation (comprehensive)" do
test "complete isolation between organizations" do
# Set up two organizations with users and posts
org1 = create_organization(name: "Org 1")
org2 = create_organization(name: "Org 2")
user1 = create_user(organization_id: org1.id, email: "user1@org1.com")
user2 = create_user(organization_id: org2.id, email: "user2@org2.com")
post1a = create_post(organization_id: org1.id, author_id: user1.id, title: "Post 1A")
post1b = create_post(organization_id: org1.id, author_id: user1.id, title: "Post 1B")
post2a = create_post(organization_id: org2.id, author_id: user2.id, title: "Post 2A")
# Verify org1 can only see org1 posts
{:ok, org1_posts} = Post.by_organization(org1.id)
assert length(org1_posts) == 2
assert Enum.all?(org1_posts, &(&1.organization_id == org1.id))
# Verify org2 can only see org2 posts
{:ok, org2_posts} = Post.by_organization(org2.id)
assert length(org2_posts) == 1
assert hd(org2_posts).organization_id == org2.id
assert hd(org2_posts).id == post2a.id
# Verify can't access other org's posts
assert {:error, _} = Post.get(post2a.id, org1.id)
assert {:error, _} = Post.get(post1a.id, org2.id)
end
end
end
Authorization Patterns
Pattern 1: Implicit Organization Filtering
elixir
# In controller or API
def list_posts(conn, _params) do
current_user = conn.assigns.current_user
org_id = current_user.organization_id
# Automatically scoped to user's organization
{:ok, posts} = Post.by_organization(org_id)
render(conn, "index.json", posts: posts)
end
Pattern 2: Explicit Organization Check
elixir
def show_post(conn, %{"id" => post_id}) do
current_user = conn.assigns.current_user
org_id = current_user.organization_id
# Will only return post if it belongs to user's organization
case Post.get(post_id, org_id) do
{:ok, post} ->
render(conn, "show.json", post: post)
{:error, _} ->
send_resp(conn, 404, "Not found")
end
end
Pattern 3: Ash Policies (Advanced)
elixir
policies do
policy action_type(:read) do
authorize_if expr(organization_id == ^actor(:organization_id))
end
policy action_type(:create) do
authorize_if expr(organization_id == ^actor(:organization_id))
end
policy action_type([:update, :destroy]) do
authorize_if expr(
organization_id == ^actor(:organization_id) and
author_id == ^actor(:id)
)
end
end
ElectricSQL Integration
Migration with ELECTRIC GRANT
elixir
def up do
create table(:posts, primary_key: false) do
# ... columns ...
end
# Enable ElectricSQL sync
execute "ALTER TABLE posts REPLICA IDENTITY FULL"
# Grant with organization filtering
execute """
ELECTRIC GRANT SELECT ON posts
TO AUTHENTICATED
WHERE organization_id = auth.organization_id()
"""
# Or for development (less secure):
# execute "ELECTRIC GRANT ALL ON posts TO ANYONE"
end
Frontend Subscription (Organization-Filtered)
typescript
// frontend/src/lib/electric/sync.ts
import { ShapeStream } from '@electric-sql/client'
export async function syncPosts(organizationId: string) {
const stream = new ShapeStream({
url: `${PUBLIC_ELECTRIC_URL}/v1/shape`,
params: {
table: 'posts',
where: `organization_id='${organizationId}'` // ← Organization filter
}
})
stream.subscribe((messages) => {
// Only receives posts for this organization
db.collections.posts.load(messages)
})
}
Quick Reference
Checklist for Multi-Tenant Resources
- • Includes
organization_idattribute (allow_nil?: false) - • Has
belongs_to :organizationrelationship - • organization_id is NOT in update action's accept list
- • Has
by_organizationread action with filter - • Includes index on organization_id in migration
- • Tests organization isolation
- • Uses organization-scoped queries in controllers
- • ElectricSQL grants filter by organization (if syncing)
Code Template
elixir
attributes do
uuid_primary_key :id
attribute :organization_id, :uuid, allow_nil?: false
create_timestamp :inserted_at
update_timestamp :updated_at
end
relationships do
belongs_to :organization, MyApp.Auth.Organization, allow_nil?: false
end
actions do
create :create do
accept [:organization_id, ...]
end
update :update do
accept [...] # NO organization_id
end
read :by_organization do
argument :organization_id, :uuid, allow_nil?: false
filter expr(organization_id == ^arg(:organization_id))
end
end
Related Skills
- •Creating Ash Resources:
.claude/skills/creating-ash-resources/- Basic resource creation - •ElectricSQL Sync:
.claude/skills/electricsql-sync-setup/- Real-time sync with tenant filtering
Key Takeaways
- •✅ Every domain resource must include
organization_id - •✅ organization_id is immutable after creation
- •✅ Always filter queries by organization
- •✅ Index organization_id for performance
- •✅ Test organization isolation thoroughly
- •✅ Use scoped read actions as primary
- •✅ Validate cross-resource organization consistency
- •❌ Never allow organization_id updates
- •❌ Never query without organization filter (except admin)
- •❌ Never expose unscoped read actions to non-admins