├── .gitignore ├── README.md ├── single_org ├── sysadmin_implementation.md ├── todos.md └── authorization_strategy.md └── multi_org ├── todos.md └── authorization_strategy.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phoenix 1.8 Multi-Tenant Authorization Strategies 2 | 3 | This repository contains comprehensive guides and implementation strategies for building multi-tenant applications using Phoenix 1.8. The documentation focuses on two different multi-tenancy approaches with role-based authorization. 4 | 5 | ## Overview 6 | 7 | Multi-tenancy allows a single application instance to serve multiple tenants (organizations or teams) while maintaining proper data isolation. This repository documents two distinct approaches to implementing multi-tenancy in Phoenix 1.8 applications: 8 | 9 | 1. **Single-Organization Model**: Each user belongs to exactly one organization at a time 10 | 2. **Multi-Organization Model**: Users can belong to multiple organizations simultaneously with different roles in each 11 | 12 | Both approaches use Phoenix 1.8's new Scopes feature and implement role-based authorization using Bodyguard. 13 | 14 | ## Directory Structure 15 | 16 | 17 | ## Tenancy Approaches 18 | 19 | ### Single-Organization Model 20 | 21 | The single-organization approach is simpler to implement and suitable for applications where users typically belong to just one organization. Key features: 22 | 23 | - Organization ID is stored directly on the user record 24 | - Each user has a single role (admin, manager, member) within their organization 25 | - Simpler data structure and queries 26 | - Includes system administrator functionality that spans across organizations 27 | 28 | **See:** [Single-Organization Guide](./single_org/authorization_strategy.md) 29 | 30 | ### Multi-Organization Model 31 | 32 | The multi-organization approach offers greater flexibility and is ideal for applications where users need to be members of multiple organizations with different roles in each. Key features: 33 | 34 | - Uses a join table (organization_memberships) to connect users to organizations 35 | - Users can have different roles in different organizations 36 | - More complex data structure but offers greater flexibility 37 | - Includes organization switching functionality 38 | 39 | **See:** [Multi-Organization Guide](./multi_org/authorization_strategy.md) 40 | 41 | ## Implementation Checklists 42 | 43 | Both approaches include detailed implementation checklists to guide the development process: 44 | 45 | - [Single-Organization Implementation Checklist](./single_org/todos.md) 46 | - [Multi-Organization Implementation Checklist](./multi_org/todos.md) 47 | 48 | ## Key Components Covered 49 | 50 | Both guides cover: 51 | 52 | - Database schema design and migrations 53 | - Phoenix authentication integration with phx.gen.auth 54 | - Using Phoenix 1.8's new Scopes for tenant isolation 55 | - Role-based authorization with Bodyguard 56 | - LiveView implementation with Phoenix 1.8's component-based approach 57 | - Comprehensive testing strategies 58 | 59 | ## How to Use These Documents 60 | 61 | 1. Choose the appropriate multi-tenancy model for your application requirements 62 | 2. Follow the detailed implementation guide for your chosen approach 63 | 3. Use the implementation checklist to track progress 64 | 4. Reference the specific guides for specialized features (e.g., system administrator implementation) 65 | 66 | ## License 67 | 68 | This documentation is provided for educational purposes. Feel free to use these strategies in your own applications. 69 | -------------------------------------------------------------------------------- /single_org/sysadmin_implementation.md: -------------------------------------------------------------------------------- 1 | # System Administrator Implementation Guide 2 | 3 | ## Overview 4 | 5 | This document outlines the implementation plan for adding system administrator functionality to the Catalyst single-organization application. A system administrator (sysadmin) is a user with special privileges that span across all organizations in the system, unlike regular roles (admin, manager, member) which are scoped to a single organization. 6 | 7 | ## Database Changes 8 | 9 | ### User Schema Update 10 | 11 | Add a boolean `is_sysadmin` field to the User schema: 12 | 13 | ```elixir 14 | # In the users table migration 15 | defmodule Catalyst.Repo.Migrations.AddIsSysadminToUsers do 16 | use Ecto.Migration 17 | 18 | def change do 19 | alter table(:users) do 20 | add :is_sysadmin, :boolean, default: false, null: false 21 | end 22 | 23 | # Optional: Create an index for faster queries 24 | create index(:users, [:is_sysadmin]) 25 | end 26 | end 27 | ``` 28 | 29 | ### User Schema Definition 30 | 31 | Update the User schema to include the new field: 32 | 33 | ```elixir 34 | # lib/catalyst/accounts/user.ex 35 | defmodule Catalyst.Accounts.User do 36 | use Ecto.Schema 37 | import Ecto.Changeset 38 | 39 | @roles ~w(admin manager member)a 40 | 41 | schema "users" do 42 | field :email, :string 43 | field :hashed_password, :string 44 | field :confirmed_at, :utc_datetime 45 | field :role, :string, default: "member" 46 | field :is_sysadmin, :boolean, default: false 47 | # Virtual fields for password (from phx.gen.auth) 48 | field :password, :string, virtual: true 49 | field :current_password, :string, virtual: true 50 | 51 | belongs_to :organization, Catalyst.Accounts.Organization 52 | 53 | timestamps() 54 | end 55 | 56 | # Add a helper function for guard clauses 57 | def is_sysadmin?(%__MODULE__{is_sysadmin: true}), do: true 58 | def is_sysadmin?(_), do: false 59 | 60 | # Add a guard macro for use in function guards 61 | defguard is_sysadmin(user) when user.is_sysadmin == true 62 | 63 | # Add a changeset function for updating sysadmin status 64 | def sysadmin_changeset(user, attrs) do 65 | user 66 | |> cast(attrs, [:is_sysadmin]) 67 | end 68 | 69 | # Existing changesets... 70 | def registration_changeset(user, attrs) do 71 | user 72 | |> cast(attrs, [:email, :password]) 73 | |> validate_email() 74 | |> validate_password() 75 | end 76 | 77 | def role_changeset(user, attrs) do 78 | user 79 | |> cast(attrs, [:role]) 80 | |> validate_inclusion(:role, Enum.map(@roles, &Atom.to_string/1)) 81 | end 82 | 83 | # ... other existing functions 84 | end 85 | ``` 86 | 87 | ## Authorization Updates 88 | 89 | ### Bodyguard Policy Updates 90 | 91 | Update the Bodyguard policies to check for sysadmin status: 92 | 93 | ```elixir 94 | defmodule Catalyst.Accounts.Policy do 95 | @behaviour Bodyguard.Policy 96 | 97 | alias Catalyst.Accounts.{User, Organization} 98 | 99 | # Sysadmins can perform any action on any organization 100 | def authorize(_action, %User{is_sysadmin: true}, _params), do: :ok 101 | 102 | # Existing organization-specific rules 103 | def authorize(:update_organization, %User{role: "admin", organization_id: org_id}, %Organization{id: org_id}), do: :ok 104 | def authorize(:invite_user, %User{role: "admin", organization_id: org_id}, %Organization{id: org_id}), do: :ok 105 | 106 | # ... other existing rules 107 | 108 | # Fallback 109 | def authorize(_action, _user, _params), do: :error 110 | end 111 | ``` 112 | 113 | ### System-wide Administration Functions 114 | 115 | Add functions for system-wide administration that only sysadmins can access: 116 | 117 | ```elixir 118 | defmodule Catalyst.Accounts do 119 | # ... existing code 120 | 121 | # List all organizations (sysadmin only) 122 | def list_organizations(%Scope{user: %User{} = user}) do 123 | if User.is_sysadmin?(user) do 124 | Repo.all(Organization) 125 | else 126 | {:error, :unauthorized} 127 | end 128 | end 129 | 130 | # Make a user a sysadmin (sysadmin only) 131 | def update_sysadmin_status(%Scope{user: %User{} = current_user}, %User{} = target_user, is_sysadmin) do 132 | if User.is_sysadmin?(current_user) do 133 | target_user 134 | |> User.sysadmin_changeset(%{is_sysadmin: is_sysadmin}) 135 | |> Repo.update() 136 | else 137 | {:error, :unauthorized} 138 | end 139 | end 140 | 141 | # ... other sysadmin-only functions 142 | end 143 | ``` 144 | 145 | ## UI Implementation 146 | 147 | ### Sysadmin Dashboard 148 | 149 | Create a sysadmin dashboard that's only accessible to users with is_sysadmin=true: 150 | 151 | ```elixir 152 | # In router.ex 153 | pipeline :require_sysadmin do 154 | plug :ensure_sysadmin 155 | end 156 | 157 | scope "/sysadmin", CatalystWeb do 158 | pipe_through [:browser, :require_authenticated_user, :require_sysadmin] 159 | 160 | live "/dashboard", SysadminDashboardLive, :index 161 | live "/organizations", SysadminOrganizationsLive, :index 162 | live "/users", SysadminUsersLive, :index 163 | # ... other sysadmin routes 164 | end 165 | 166 | # In user_auth.ex 167 | def ensure_sysadmin(conn, _opts) do 168 | if conn.assigns.current_user && conn.assigns.current_user.is_sysadmin do 169 | conn 170 | else 171 | conn 172 | |> put_flash(:error, "You must be a system administrator to access this page.") 173 | |> redirect(to: ~p"/") 174 | |> halt() 175 | end 176 | end 177 | 178 | def on_mount(:ensure_sysadmin, _params, _session, socket) do 179 | if socket.assigns.current_user && socket.assigns.current_user.is_sysadmin do 180 | {:cont, socket} 181 | else 182 | {:halt, socket 183 | |> put_flash(:error, "You must be a system administrator to access this page.") 184 | |> redirect(to: ~p"/")} 185 | end 186 | end 187 | ``` 188 | 189 | ## Guard Clause Usage 190 | 191 | The `is_sysadmin?/1` function can be used in guard clauses throughout the application: 192 | 193 | ```elixir 194 | # Example usage in a context function 195 | def some_sensitive_function(%Scope{user: user}, params) when is_sysadmin(user) do 196 | # Function body that only sysadmins can execute 197 | end 198 | 199 | # This will raise a FunctionClauseError for non-sysadmins 200 | def some_sensitive_function(%Scope{}, _params) do 201 | {:error, :unauthorized} 202 | end 203 | ``` 204 | 205 | ## Testing 206 | 207 | Add tests for the sysadmin functionality: 208 | 209 | ```elixir 210 | defmodule Catalyst.AccountsTest do 211 | # ... existing setup 212 | 213 | describe "sysadmin authorization" do 214 | test "sysadmin can list all organizations" do 215 | # Create test organizations 216 | org1 = insert(:organization) 217 | org2 = insert(:organization) 218 | 219 | # Create a sysadmin user 220 | sysadmin = insert(:user, is_sysadmin: true) 221 | 222 | # Create a scope with the sysadmin 223 | scope = Scope.for_user(sysadmin) 224 | 225 | # Test that sysadmin can list all organizations 226 | orgs = Accounts.list_organizations(scope) 227 | assert length(orgs) >= 2 228 | assert Enum.any?(orgs, fn org -> org.id == org1.id end) 229 | assert Enum.any?(orgs, fn org -> org.id == org2.id end) 230 | end 231 | 232 | test "regular admin cannot list all organizations" do 233 | # Create test organizations 234 | org1 = insert(:organization) 235 | org2 = insert(:organization) 236 | 237 | # Create a regular admin user 238 | admin = insert(:user, organization: org1, role: "admin") 239 | 240 | # Create a scope with the admin 241 | scope = Scope.for_user(admin) 242 | 243 | # Test that admin cannot list all organizations 244 | assert {:error, :unauthorized} = Accounts.list_organizations(scope) 245 | end 246 | 247 | # ... other tests 248 | end 249 | end 250 | ``` 251 | 252 | ## Conclusion 253 | 254 | Adding a boolean `is_sysadmin` field to the User schema provides a clean way to implement system-wide administrative capabilities that transcend organization boundaries. The `is_sysadmin?/1` function works well in guard clauses for authorization checks, making it easy to restrict certain functions to system administrators only. 255 | 256 | This approach maintains the single-organization model for regular users while adding a special class of users who can manage the entire system. 257 | -------------------------------------------------------------------------------- /single_org/todos.md: -------------------------------------------------------------------------------- 1 | # Multi-Tenant Authorization Strategy Implementation Checklist 2 | 3 | ## At-a-Glance Summary 4 | 5 | **Single-Organization-Per-User Multi-tenancy Implementation:** 6 | - Add organization_id (non-null) and role fields to users table 7 | - Create organizations table with basic fields (name, slug, etc.) 8 | - Implement Scope struct to include organization from user 9 | - Configure authorization based on user.role within their organization 10 | - Add system administrator role for cross-organization management 11 | - Update all contexts to respect organization boundaries 12 | - Implement appropriate UI for organization management 13 | 14 | ## Progress Update (April 3, 2025) 15 | 16 | We're transitioning from a multi-organization to a single-organization model: 17 | 18 | - Created the organizations migration and schema 19 | - Need to update the User schema to include organization_id and role 20 | - Need to update the Organization schema to have a direct relationship with users 21 | - Need to enhance the Scope struct for the single-organization model 22 | - Need to update tests for the single-organization approach 23 | - **PRIORITY:** Need to implement system administrator functionality that spans across organizations 24 | 25 | ## Implementation Note 26 | 27 | > **IMPORTANT:** We will follow the single-organization-per-user model as described in the authorization_strategy.md guide (with organization_id on the users table). This means: 28 | > 29 | > - Each user belongs to exactly one organization at a time 30 | > - Permissions/roles are managed directly on the users table 31 | > - Each user has a single role (admin, manager, member) within their organization 32 | > - All context functions will be designed with single-org membership in mind 33 | > - The UI will not include organization switching functionality 34 | > 35 | > The single-organization approach simplifies the implementation and is sufficient for many business applications where users typically belong to just one organization. 36 | 37 | ## Task Assignment Legend 38 | 39 | - [BE] - Backend developer task 40 | - [FE] - Frontend developer task 41 | - [FULL] - Full-stack developer task 42 | - [DB] - Database administrator task 43 | - [TEST] - QA or test engineer task 44 | - [P1] - Priority 1 (Must be completed first) 45 | - [P2] - Priority 2 (Can be started after P1 tasks) 46 | - [P3] - Priority 3 (Can be started in parallel with P2 tasks) 47 | 48 | ## 1. Database Setup 49 | 50 | _See "Database Schema and Associations" section in authorization_strategy.md_ 51 | 52 | - [ ] [DB] [P1] Create migration for organizations table with fields: 53 | 54 | - [ ] name (string, not null) 55 | - [ ] description 56 | - [ ] slug (string, not null, with unique index) 57 | - [ ] active (boolean, default true) 58 | - [ ] timestamps 59 | 60 | - [ ] [DB] [P1] Update users table to add the following fields: 61 | 62 | - [ ] organization_id (foreign key to organizations, with delete cascade, null: false) 63 | - [ ] role (string, default "member", null: false) 64 | - [ ] Add index on organization_id for faster queries 65 | 66 | - [ ] [DB] [P1] Clean up from multi-org approach: 67 | - [ ] Remove organization_memberships table if it exists 68 | - [ ] Remove any other multi-org specific tables or fields 69 | 70 | ## 2. Schema Definitions 71 | 72 | _See "Database Schema and Associations" section in authorization_strategy.md_ 73 | 74 | - [ ] [BE] [P1] Update Organization schema: 75 | - [ ] Define has_many relationship with users 76 | - [ ] Create changeset function with validations 77 | - [ ] Use @derive directive for slug-based URLs: 78 | ```elixir 79 | @derive {Phoenix.Param, key: :slug} 80 | ``` 81 | - [ ] [BE] [P1] Update User schema: 82 | - [ ] Define belongs_to relationship with organization 83 | - [ ] Add role field with proper validation 84 | - [ ] Define allowed roles as module attribute (e.g., @roles ~w(admin manager member)a) 85 | - [ ] Ensure organization_id is required in changesets 86 | 87 | ## 3. Extend Scope for Single-Organization Model 88 | 89 | _See "Using Phoenix 1.8 Scopes for Tenant-Specific Context and Routing" section in authorization_strategy.md_ 90 | 91 | - [ ] [BE] [P1] Modify Catalyst.Accounts.Scope struct to include organization field 92 | - [ ] [BE] [P1] Update for_user/1 function to automatically set organization from user.organization_id 93 | - [ ] [BE] [P1] Ensure access to role info is available directly through scope.user.role 94 | - [ ] [BE] [P2] Add scope helpers for IEx development experience in .iex.exs 95 | 96 | ```elixir 97 | def for(opts) when is_list(opts) do 98 | cond do 99 | opts[:user] -> 100 | user = user(opts[:user]) 101 | # In single-org model, organization is loaded with the user 102 | for_user(user) 103 | 104 | opts[:org] -> 105 | # For when we have an org but no user (e.g., public pages) 106 | org = org(opts[:org]) 107 | %__MODULE__{organization: org} 108 | end 109 | end 110 | ``` 111 | 112 | ## 4. Configure Organization Scope 113 | 114 | _See "Automatic Scope in Context Functions and Migrations" section in authorization_strategy.md_ 115 | 116 | - [ ] [BE] [P1] Update config/config.exs to add organization scope: 117 | 118 | ```elixir 119 | config :catalyst, :scopes, 120 | organization: [ 121 | module: Catalyst.Accounts.Scope, 122 | assign_key: :current_scope, 123 | access_path: [:organization, :id], 124 | route_prefix: "/orgs/:org", 125 | schema_key: :organization_id, 126 | schema_type: :binary_id, 127 | schema_table: :organizations 128 | ] 129 | ``` 130 | 131 | ## 5. System Administrator Implementation 132 | 133 | _See "System Administrator Role" section in authorization_strategy.md and sysadmin_implementation.md_ 134 | 135 | - [ ] [DB] [P1] Add is_sysadmin field to users table: 136 | 137 | ```elixir 138 | # In the users table migration 139 | defmodule Catalyst.Repo.Migrations.AddIsSysadminToUsers do 140 | use Ecto.Migration 141 | 142 | def change do 143 | alter table(:users) do 144 | add :is_sysadmin, :boolean, default: false, null: false 145 | end 146 | 147 | # Optional: Create an index for faster queries 148 | create index(:users, [:is_sysadmin]) 149 | end 150 | end 151 | ``` 152 | 153 | - [ ] [BE] [P1] Update User schema to include sysadmin field and helpers 154 | - [ ] [BE] [P1] Update Bodyguard policy to check for sysadmin status 155 | - [ ] [BE] [P1] Add system-wide administration functions 156 | - [ ] [BE] [P2] Create sysadmin-only routes and controllers/LiveViews 157 | - [ ] [BE] [P2] Implement sysadmin access control plug 158 | - [ ] [FE] [P2] Create sysadmin dashboard and management UIs 159 | - [ ] [TEST] [P2] Test sysadmin functionality 160 | 161 | ## 6. Organization Context Functions 162 | 163 | _See "Context Associations and Preloading" section in authorization_strategy.md_ 164 | 165 | - [ ] [BE] [P2] Create or extend Accounts context with organization functions: 166 | - [ ] get_organization!/2 (with scope) 167 | - [ ] get_organization_by_slug/1 and get_organization_by_slug!/1 168 | - [ ] create_organization/2 (with scope, setting the creator as admin) 169 | - [ ] update_organization/3 (with scope and role check) 170 | - [ ] delete_organization/2 (with scope and admin role check) 171 | - [ ] change_organization/1 for changesets 172 | 173 | - [ ] [BE] [P2] Add user role management functions: 174 | - [ ] list_users_by_organization/1 (with scope) 175 | - [ ] update_user_role/3 (user, new_role, with scope) 176 | - [ ] Implement "last admin" protection 177 | 178 | - [ ] [BE] [P2] Ensure proper query scoping in all context functions 179 | 180 | ## 7. Router and Plugs Setup 181 | 182 | _See "Scoped Routes with Organization Prefix" section in authorization_strategy.md_ 183 | 184 | - [ ] [BE] [P2] Add verify_organization_access plug to browser pipeline 185 | - [ ] [BE] [P2] Implement verify_organization_access function: 186 | - [ ] Get organization by slug 187 | - [ ] Verify user belongs to that organization 188 | - [ ] Halt with error if user doesn't belong to the organization 189 | - [ ] [BE] [P2] Create on_mount callback for LiveViews 190 | - [ ] [BE] [P2] Define organization-scoped routes with proper live_session setup 191 | - [ ] [BE] [P3] Handle case when user has no organization (redirect to create org) 192 | 193 | ## 8. User Registration and Login Flow 194 | 195 | _See "Authentication with phx.gen.auth for Multi-Organization Support" section in authorization_strategy.md_ 196 | 197 | - [ ] [FULL] [P2] Modify user registration to create organization: 198 | - [ ] Allow new users to create their organization during signup 199 | - [ ] Set the creator as admin of the organization 200 | 201 | - [ ] [FULL] [P2] Update login flow for single organization: 202 | - [ ] After login, redirect directly to the user's organization dashboard 203 | - [ ] Handle case when user has no organization (redirect to create one) 204 | 205 | - [ ] [FULL] [P2] Implement organization invitation workflow: 206 | - [ ] Generate and store secure invitation tokens 207 | - [ ] Send invitation emails with secure tokens 208 | - [ ] Create invitation acceptance page with token validation 209 | - [ ] For new users: Complete registration and join organization 210 | - [ ] For existing users: Handle organization transfer with clear warnings 211 | - [ ] Confirm leaving current organization 212 | - [ ] Explain that all access to previous organization data will be lost 213 | - [ ] Provide option to reject invitation and remain in current organization 214 | - [ ] Set proper expiration for invitation tokens 215 | - [ ] Allow organization admins to revoke pending invitations 216 | 217 | ## 9. Bodyguard Authorization Setup 218 | 219 | _See "Role-Based Authorization with Bodyguard" section in authorization_strategy.md_ 220 | 221 | - [ ] [BE] [P2] Add Bodyguard dependency to mix.exs 222 | - [ ] [BE] [P2] Create role definitions (admin, manager, member) 223 | - [ ] [BE] [P2] Create dedicated policy modules 224 | - [ ] [BE] [P2] Implement policy authorization functions for different actions 225 | - [ ] [BE] [P2] Use user.role directly for role-based checks 226 | - [ ] [BE] [P2] Add organization-specific authorization rules 227 | - [ ] [BE] [P2] Create policy helpers for common checks 228 | 229 | ## 10. Enforce Authorization in Context Functions 230 | 231 | _See "Role-Based Authorization with Bodyguard" section in authorization_strategy.md_ 232 | 233 | - [ ] [BE] [P2] Add authorization checks directly in context functions 234 | - [ ] [BE] [P2] Create dedicated context helper for authorization 235 | - [ ] [BE] [P2] Implement pre-check authorization for bulk operations 236 | - [ ] [BE] [P2] Add query-level authorization using Ecto queries 237 | - [ ] [BE] [P2] Ensure all public context functions that modify data check authorization 238 | - [ ] [BE] [P3] Create helper functions for common authorization patterns 239 | 240 | ## 11. Enforce Authorization in Controllers/LiveViews 241 | 242 | _See "Enforcing Authorization in Controllers and LiveViews" section in authorization_strategy.md_ 243 | 244 | - [ ] [FULL] [P3] For controllers: use Bodyguard.permit/4 or Bodyguard.Plug.Authorize 245 | - [ ] [FULL] [P3] For LiveViews: Add authorization checks in mount and event handlers 246 | - [ ] [FE] [P3] Add UI conditionals based on membership roles 247 | - [ ] [BE] [P3] Create reusable helpers for common authorization checks 248 | 249 | ## 12. LiveView UI Implementation 250 | 251 | _See "LiveView 1.0 Integration and UI Implementation" section in authorization_strategy.md_ 252 | 253 | - [ ] [FE] [P3] Create Organization management LiveViews 254 | - [ ] [FULL] [P3] Create Organization invitation workflow 255 | - [ ] [FE] [P3] Use Phoenix.Component.to_form/1 for forms 256 | - [ ] [FE] [P3] Use Phoenix.Component components for UI 257 | - [ ] [FE] [P3] Ensure all navigation includes organization context 258 | - [ ] [BE] [P3] Implement organization-aware LiveView subscriptions 259 | 260 | ## 13. Testing 261 | 262 | _See various sections in authorization_strategy.md_ 263 | 264 | - [ ] [TEST] [P3] Write tests for organization context functions 265 | - [ ] [TEST] [P3] Write tests for user role management functions 266 | - [ ] [TEST] [P3] Test Bodyguard policies directly 267 | - [ ] [TEST] [P3] Test "last admin" protection 268 | - [ ] [TEST] [P3] Create test helpers for organizations and users fixtures 269 | - [ ] [TEST] [P3] Write integration tests for UI flows 270 | - [ ] [TEST] [P3] Test organization data isolation 271 | - [ ] [TEST] [P3] Test invitation flow 272 | - [ ] [TEST] [P3] Test sysadmin functionality 273 | 274 | ## 14. UI/UX Polishing 275 | 276 | _See "LiveView 1.0 Integration and UI Implementation" section in authorization_strategy.md_ 277 | 278 | - [ ] [FE] [P3] Add organization name to layout header 279 | - [ ] [FE] [P3] Add role-based UI elements 280 | - [ ] [FE] [P3] Implement organization settings page 281 | - [ ] [FE] [P3] Create user role management UI 282 | - [ ] [FE] [P3] Add invitation management UI 283 | - [ ] [FE] [P3] Implement organization creation flow 284 | - [ ] [FE] [P3] Add proper error handling and feedback 285 | - [ ] [FE] [P3] Ensure mobile responsiveness 286 | - [ ] [FE] [P3] Add loading states for async operations 287 | -------------------------------------------------------------------------------- /multi_org/todos.md: -------------------------------------------------------------------------------- 1 | # Multi-Tenant Authorization Strategy Implementation Checklist 2 | 3 | ## Progress Update (April 3, 2025) 4 | 5 | We've successfully implemented the foundation for multi-tenant organization support: 6 | 7 | - Created the organization_memberships migration and schema ✅ 8 | - Updated the Organization schema to support many-to-many relationships ✅ 9 | - Updated the User schema to support many-to-many relationships ✅ 10 | - Enhanced the Scope struct to include organization and membership ✅ 11 | - Added tests for organization membership and scope functionality ✅ 12 | 13 | Following tasks are already completed or in progress: 14 | 15 | ## Implementation Note 16 | 17 | > **IMPORTANT:** While the authorization_strategy.md guide primarily describes a single-organization-per-user model (with organization_id on the users table), **our implementation will follow the multi-organization membership approach** as outlined in this checklist. This means: 18 | > 19 | > - Users can belong to multiple organizations simultaneously 20 | > - Permissions/roles are managed through a join table (organization_memberships) 21 | > - Each organization membership has its own role (admin, manager, member) 22 | > - All context functions will be designed with multi-org membership in mind 23 | > - The UI will include organization switching functionality 24 | > 25 | > The multi-organization approach offers greater flexibility and better matches modern SaaS application requirements, where users commonly need access to multiple organizations with different permission levels in each. 26 | 27 | ## Task Assignment Legend 28 | 29 | - [BE] - Backend developer task 30 | - [FE] - Frontend developer task 31 | - [FULL] - Full-stack developer task 32 | - [DB] - Database administrator task 33 | - [TEST] - QA or test engineer task 34 | - [P1] - Priority 1 (Must be completed first) 35 | - [P2] - Priority 2 (Can be started after P1 tasks) 36 | - [P3] - Priority 3 (Can be started in parallel with P2 tasks) 37 | 38 | ## 1. Database Setup 39 | 40 | _See "Database Schema and Associations" section in authorization_strategy.md_ 41 | 42 | - [x] [DB] [P1] Create migration for organizations table with fields: 43 | 44 | - [x] name (string, not null) 45 | - [x] description 46 | - [x] slug (string, not null, with unique index) 47 | - [x] active (boolean, default true) 48 | - [x] timestamps 49 | 50 | - [x] [DB] [P1] Create migration for organization_memberships join table with fields: 51 | 52 | - [x] user_id (foreign key to users, with delete cascade) 53 | - [x] organization_id (foreign key to organizations, with delete cascade) 54 | - [x] role (string, default "member") 55 | - [x] Add unique constraint on (user_id, organization_id) to prevent duplicate memberships 56 | - [x] timestamps 57 | 58 | - [x] [DB] [P1] Ensure users table doesn't have organization_id or role fields (these move to membership table) 59 | 60 | ## 2. Schema Definitions 61 | 62 | _See "Database Schema and Associations" section in authorization_strategy.md_ 63 | 64 | - [x] [BE] [P1] Create Organization schema: 65 | - [x] Define many_to_many relationship with users through organization_memberships 66 | - [x] Create changeset function with validations 67 | - [x] Use @derive directive for slug-based URLs: 68 | ```elixir 69 | @derive {Phoenix.Param, key: :slug} 70 | ``` 71 | - [x] [BE] [P1] Create OrganizationMembership schema: 72 | - [x] Define belongs_to relationship with users and organizations 73 | - [x] Add role field with proper validation 74 | - [x] Create changeset function with validations 75 | - [x] Define allowed roles as module attribute (e.g., @roles ~w(admin manager member)a) 76 | - [x] [BE] [P1] Update User schema: 77 | - [x] Define many_to_many relationship with organizations through organization_memberships 78 | - [x] Update functions to handle multiple organization memberships 79 | 80 | ## 3. Extend Scope for Multi-Tenancy 81 | 82 | _See "Using Phoenix 1.8 Scopes for Tenant-Specific Context and Routing" section in authorization_strategy.md_ 83 | 84 | - [x] [BE] [P1] Modify Catalyst.Accounts.Scope struct to include organization field and membership 85 | - [x] [BE] [P1] Update for_user/1 function to not automatically set organization (since a user has multiple) 86 | - [x] [BE] [P1] Add put_organization/2 helper function that also sets the correct membership for that org 87 | - [x] [BE] [P1] Create get_membership/2 helper to find the membership for a user+org combination 88 | - [x] [BE] [P1] Ensure access to role info is available through scope.membership.role 89 | - [x] [BE] [P2] Add scope helpers for IEx development experience: 90 | 91 | ```elixir 92 | def for(opts) when is_list(opts) do 93 | cond do 94 | opts[:user] && opts[:org] -> 95 | user = user(opts[:user]) 96 | org = org(opts[:org]) 97 | membership = get_membership(user, org) 98 | 99 | user 100 | |> for_user() 101 | |> put_organization(org) 102 | |> put_membership(membership) 103 | 104 | # Other conditions... 105 | end 106 | end 107 | ``` 108 | 109 | ## 4. Configure Organization Scope 110 | 111 | _See "Automatic Scope in Context Functions and Migrations" section in authorization_strategy.md_ 112 | 113 | - [ ] [BE] [P1] Update config/config.exs to add organization scope: 114 | 115 | ```elixir 116 | config :catalyst, :scopes, 117 | organization: [ 118 | module: Catalyst.Accounts.Scope, 119 | assign_key: :current_scope, 120 | access_path: [:organization, :id], 121 | route_prefix: "/orgs/:org", 122 | schema_key: :organization_id, 123 | schema_type: :binary_id, 124 | schema_table: :organizations 125 | ] 126 | ``` 127 | 128 | ## 5. Organization and Membership Context Functions 129 | 130 | _See "Context Associations and Preloading" section in authorization_strategy.md_ 131 | 132 | - [ ] [BE] [P2] Create or extend Accounts context with organization functions: 133 | 134 | - [ ] get_organization!/2 (with scope) 135 | - [ ] get_organization_by_slug/1 and get_organization_by_slug!/1 136 | - [ ] list_organizations/1 (for a specific user via scope) 137 | - [ ] create_organization/2 (with scope and creating initial membership) 138 | - [ ] update_organization/3 (with scope and membership check) 139 | - [ ] delete_organization/2 (with scope and ownership check) 140 | - [ ] change_organization/1 for changesets 141 | 142 | - [ ] [BE] [P2] Add membership management functions: 143 | 144 | - [ ] get_membership/2 (user_id, org_id) 145 | - [ ] list_memberships/1 (for a user or an organization) 146 | - [ ] create_membership/3 (user, organization, role) 147 | - [ ] update_membership/2 (membership, attrs - e.g., change role) 148 | - [ ] delete_membership/1 (remove user from organization) 149 | - [ ] Implement "last admin" protection: 150 | 151 | ```elixir 152 | def update_membership(%Scope{} = scope, %OrganizationMembership{} = membership, attrs) do 153 | with :ok <- authorize_membership_update(scope, membership, attrs), 154 | :ok <- ensure_not_removing_last_admin(membership, attrs), 155 | changeset <- OrganizationMembership.changeset(membership, attrs), 156 | {:ok, updated} <- Repo.update(changeset) do 157 | {:ok, updated} 158 | end 159 | end 160 | 161 | defp ensure_not_removing_last_admin(%OrganizationMembership{role: "admin", organization_id: org_id}, %{role: new_role}) 162 | when new_role != "admin" do 163 | # Count admins in this organization 164 | admin_count = Repo.one(from m in OrganizationMembership, 165 | where: m.organization_id == ^org_id and m.role == "admin", 166 | select: count()) 167 | if admin_count <= 1, do: {:error, :last_admin}, else: :ok 168 | end 169 | defp ensure_not_removing_last_admin(_, _), do: :ok 170 | ``` 171 | 172 | - [ ] [BE] [P2] Ensure proper query scoping in all context functions: 173 | ```elixir 174 | # Example of properly scoped query 175 | def list_projects(%Scope{} = scope) do 176 | Repo.all( 177 | from p in Project, 178 | where: p.organization_id == ^scope.organization.id 179 | ) 180 | end 181 | ``` 182 | 183 | ## 6. Router and Plugs Setup 184 | 185 | _See "Scoped Routes with Organization Prefix" section in authorization_strategy.md_ 186 | 187 | - [ ] [BE] [P2] Add assign_org_to_scope plug to browser pipeline (after fetch_current_scope_for_user) 188 | - [ ] [BE] [P2] Implement assign_org_to_scope function in UserAuth module: 189 | - [ ] Get organization by slug 190 | - [ ] Verify user has membership in that organization 191 | - [ ] Add both organization and membership to scope 192 | - [ ] [BE] [P2] Create on_mount callback for LiveViews (:assign_org_to_scope) 193 | - [ ] [BE] [P2] Define organization-scoped routes with proper live_session setup: 194 | 195 | ```elixir 196 | live_session :org_app, on_mount: [ 197 | {CatalystWeb.UserAuth, :ensure_authenticated}, 198 | {CatalystWeb.UserAuth, :mount_current_scope}, 199 | {CatalystWeb.UserAuth, :assign_org_to_scope} 200 | ] do 201 | live "/orgs/:org/dashboard", DashboardLive, :index 202 | # ...other scoped routes 203 | end 204 | ``` 205 | 206 | - [ ] [FE] [P3] Add organization switcher in layout (dropdown to select between orgs) 207 | - [ ] [BE] [P3] Handle case when user has no organizations (redirect to create org) 208 | - [ ] [BE] [P3] Add route for selecting default organization after login 209 | 210 | ## 7. User Registration and Login Flow 211 | 212 | _See "Authentication with phx.gen.auth for Multi-Organization Support" section in authorization_strategy.md_ 213 | 214 | - [ ] [FULL] [P2] Modify user registration to create organization: 215 | - [ ] Allow new users to create their first organization during signup 216 | - [ ] Create initial admin membership for the creator 217 | - [ ] [FULL] [P2] Update login flow to handle organizations: 218 | - [ ] After login, redirect to org selection if user has multiple orgs 219 | - [ ] Remember last used organization in session 220 | - [ ] Redirect to last active organization if possible 221 | - [ ] [FULL] [P2] Implement organization invitation workflow: 222 | - [ ] Generate and store secure invitation tokens: 223 | ```elixir 224 | # In database migration 225 | create table(:organization_invitations) do 226 | add :email, :string, null: false 227 | add :token, :string, null: false 228 | add :organization_id, references(:organizations), null: false 229 | add :role, :string, null: false, default: "member" 230 | add :invited_by_user_id, references(:users), null: false 231 | add :accepted_at, :utc_datetime 232 | add :expires_at, :utc_datetime, null: false 233 | timestamps() 234 | end 235 | create unique_index(:organization_invitations, [:token]) 236 | create index(:organization_invitations, [:organization_id]) 237 | create index(:organization_invitations, [:email, :organization_id], unique: true) 238 | ``` 239 | - [ ] Send invitation emails with secure tokens 240 | - [ ] Create invitation acceptance page with token validation 241 | - [ ] Handle existing users accepting new organization invites 242 | - [ ] Handle new user registrations via invitation links 243 | - [ ] Set proper expiration for invitation tokens (e.g., 7 days) 244 | - [ ] Allow organization admins to revoke pending invitations 245 | 246 | ## 8. Bodyguard Authorization Setup 247 | 248 | _See "Role-Based Authorization with Bodyguard" section in authorization_strategy.md_ 249 | 250 | - [ ] [BE] [P2] Add Bodyguard dependency to mix.exs 251 | - [ ] [BE] [P2] Create role definitions (admin, manager, member) 252 | - [ ] [BE] [P2] Create dedicated policy modules (rather than directly in contexts): 253 | 254 | ```elixir 255 | # Instead of adding Bodyguard.Policy to the context directly, 256 | # create a separate policy module: 257 | defmodule MyApp.Accounts.Policy do 258 | @behaviour Bodyguard.Policy 259 | 260 | alias MyApp.Accounts.{User, Organization, OrganizationMembership} 261 | 262 | # Authorization functions go here 263 | def authorize(action, user, params) 264 | 265 | # Helper for checking membership 266 | defp get_membership(%User{} = user, %Organization{} = org) do 267 | # Implementation 268 | end 269 | end 270 | 271 | # Configure in your context: 272 | defmodule MyApp.Accounts do 273 | use Bodyguard.Policy, policy: MyApp.Accounts.Policy 274 | 275 | # Context functions 276 | end 277 | ``` 278 | 279 | - [ ] [BE] [P2] Implement policy authorization functions for different actions 280 | - [ ] [BE] [P2] Use membership info (scope.membership.role) for role-based checks 281 | - [ ] [BE] [P2] Add organization-specific authorization rules 282 | - [ ] [BE] [P2] Create policy helpers for common checks: 283 | 284 | ```elixir 285 | defmodule MyApp.BodyguardHelpers do 286 | def member_of_org?(%User{} = user, %Organization{} = org) do 287 | # Check if user has any membership in this organization 288 | end 289 | 290 | def has_role?(user, org, roles) when is_list(roles) do 291 | # Check if user has any of the specified roles in this org 292 | end 293 | 294 | def is_owner?(user, resource) do 295 | # Check if user is the owner of a resource 296 | end 297 | end 298 | ``` 299 | 300 | - [ ] [BE] [P2] Example implementation for organization-related permissions: 301 | ```elixir 302 | def authorize(:update_organization, %User{} = user, %Organization{} = org) do 303 | with %OrganizationMembership{role: role} <- get_membership(user, org), 304 | true <- role == "admin" do 305 | :ok 306 | else 307 | _ -> :error 308 | end 309 | end 310 | ``` 311 | - [ ] [BE] [P2] Example implementation for resource-level permissions: 312 | ```elixir 313 | def authorize(:delete_project, %User{} = user, %Project{} = project) do 314 | with %OrganizationMembership{role: role} <- get_membership(user, %Organization{id: project.organization_id}), 315 | true <- role in ["admin", "manager"] or project.owner_id == user.id do 316 | :ok 317 | else 318 | _ -> :error 319 | end 320 | end 321 | ``` 322 | 323 | ## 9. Enforce Authorization in Context Functions 324 | 325 | _See "Role-Based Authorization with Bodyguard" section in authorization_strategy.md_ 326 | 327 | - [ ] [BE] [P2] Add authorization checks directly in context functions: 328 | 329 | ```elixir 330 | def update_organization(%Scope{} = scope, %Organization{} = org, attrs) do 331 | with :ok <- Bodyguard.permit(Catalyst.Accounts, :update_organization, scope.user, org), 332 | {:ok, updated_org} <- do_update_organization(org, attrs) do 333 | {:ok, updated_org} 334 | else 335 | {:error, _} = error -> error 336 | _ -> {:error, :unauthorized} 337 | end 338 | end 339 | 340 | # Private function that does the actual work after authorization 341 | defp do_update_organization(organization, attrs) do 342 | organization 343 | |> Organization.changeset(attrs) 344 | |> Repo.update() 345 | end 346 | ``` 347 | 348 | - [ ] [BE] [P2] Create dedicated context helper for authorization: 349 | ```elixir 350 | defp authorize(action, user, resource) do 351 | case Bodyguard.permit(__MODULE__, action, user, resource) do 352 | :ok -> :ok 353 | _ -> {:error, :unauthorized} 354 | end 355 | end 356 | ``` 357 | - [ ] [BE] [P2] Implement pre-check authorization for bulk operations: 358 | 359 | ```elixir 360 | def bulk_delete_projects(%Scope{} = scope, project_ids) do 361 | # First fetch all projects to verify authorization for each 362 | projects = Repo.all(from p in Project, where: p.id in ^project_ids and p.organization_id == ^scope.organization.id) 363 | 364 | # Check authorization for each project 365 | unauthorized_projects = Enum.filter(projects, fn project -> 366 | Bodyguard.permit(Catalyst.Projects, :delete_project, scope.user, project) != :ok 367 | end) 368 | 369 | if unauthorized_projects == [] do 370 | # All authorized, proceed with deletion 371 | {count, _} = Repo.delete_all(from p in Project, where: p.id in ^project_ids) 372 | {:ok, count} 373 | else 374 | {:error, :unauthorized} 375 | end 376 | end 377 | ``` 378 | 379 | - [ ] [BE] [P2] Add query-level authorization using Ecto queries: 380 | 381 | ```elixir 382 | def get_viewable_projects(%Scope{} = scope) do 383 | membership = get_membership(scope.user, scope.organization) 384 | 385 | base_query = from p in Project, 386 | where: p.organization_id == ^scope.organization.id 387 | 388 | # Different query based on role 389 | query = case membership.role do 390 | "admin" -> base_query # Admins see all 391 | "manager" -> base_query # Managers see all 392 | "member" -> from p in base_query, where: p.owner_id == ^scope.user.id or p.public == true 393 | end 394 | 395 | Repo.all(query) 396 | end 397 | ``` 398 | 399 | - [ ] [BE] [P2] Ensure all public context functions that modify data check authorization 400 | - [ ] [BE] [P3] Create helper functions for common authorization patterns 401 | - [ ] [BE] [P3] Add debug logging for authorization failures (in development) 402 | 403 | ## 10. Enforce Authorization in Controllers/LiveViews 404 | 405 | _See "Enforcing Authorization in Controllers and LiveViews" section in authorization_strategy.md_ 406 | 407 | - [ ] [FULL] [P3] For controllers: use Bodyguard.permit/4 or Bodyguard.Plug.Authorize 408 | - [ ] [FULL] [P3] For LiveViews: 409 | - [ ] Add authorization checks in mount/3 410 | - [ ] Add authorization checks in handle_event/3 callbacks 411 | - [ ] Handle unauthorized cases (redirect, flash messages, etc.) 412 | - [ ] [FE] [P3] Add UI conditionals based on membership roles (hide/show elements) 413 | - [ ] [BE] [P3] Create reusable helpers for common authorization checks: 414 | 415 | ```elixir 416 | # Example LiveView helper 417 | def authorize_org_action(socket, action) do 418 | org = socket.assigns.current_organization 419 | user = socket.assigns.current_user 420 | 421 | case Bodyguard.permit(MyApp.Accounts, action, user, org) do 422 | :ok -> {:ok, socket} 423 | _ -> {:error, socket |> put_flash(:error, "Not authorized") |> redirect(to: ~p"/orgs")} 424 | end 425 | end 426 | ``` 427 | 428 | ## 11. LiveView UI Implementation 429 | 430 | _See "LiveView 1.0 Integration and UI Implementation" section in authorization_strategy.md_ 431 | 432 | - [ ] [FE] [P3] Create Organization management LiveViews: 433 | - [ ] Organization settings form 434 | - [ ] Membership management (invite/remove/change role) 435 | - [ ] Organization switcher component 436 | - [ ] [FULL] [P3] Create Organization invitation workflow: 437 | - [ ] Generate invitation links 438 | - [ ] Accept invitation page 439 | - [ ] Handle existing vs new users for invitations 440 | - [ ] [FE] [P3] Use Phoenix.Component.to_form/1 for forms 441 | - [ ] [FE] [P3] Use Phoenix.Component components for UI 442 | - [ ] [FE] [P3] Ensure all navigation includes organization context 443 | - [ ] [BE] [P3] Implement organization-aware LiveView subscriptions: 444 | 445 | ```elixir 446 | # Example of scoped PubSub subscriptions 447 | def mount(_params, _session, socket) do 448 | if connected?(socket) do 449 | Phoenix.PubSub.subscribe(Catalyst.PubSub, "org:#{socket.assigns.current_organization.id}") 450 | end 451 | 452 | {:ok, socket} 453 | end 454 | ``` 455 | 456 | ## 12. Testing 457 | 458 | _See various sections in authorization_strategy.md_ 459 | 460 | - [ ] [TEST] [P3] Write tests for organization context functions: 461 | 462 | ```elixir 463 | test "list_organizations/1 only returns organizations for the given user" do 464 | user1 = insert(:user) 465 | user2 = insert(:user) 466 | org1 = insert(:organization) 467 | org2 = insert(:organization) 468 | 469 | insert(:organization_membership, user: user1, organization: org1) 470 | insert(:organization_membership, user: user2, organization: org2) 471 | 472 | scope1 = Scope.for_user(user1) 473 | 474 | assert [returned_org] = Accounts.list_organizations(scope1) 475 | assert returned_org.id == org1.id 476 | assert Accounts.list_organizations(Scope.for_user(user2)) != [org1] 477 | end 478 | ``` 479 | 480 | - [ ] [TEST] [P3] Write tests for membership context functions 481 | - [ ] [TEST] [P3] Test Bodyguard policies directly: 482 | 483 | ```elixir 484 | test "only admins can update organization" do 485 | # Create test data 486 | org = insert(:organization) 487 | admin = insert(:user) 488 | member = insert(:user) 489 | 490 | insert(:organization_membership, user: admin, organization: org, role: "admin") 491 | insert(:organization_membership, user: member, organization: org, role: "member") 492 | 493 | # Test policy directly 494 | assert :ok = Bodyguard.permit(MyApp.Accounts, :update_organization, admin, org) 495 | assert :error = Bodyguard.permit(MyApp.Accounts, :update_organization, member, org) 496 | end 497 | ``` 498 | 499 | - [ ] [TEST] [P3] Test "last admin" protection: 500 | 501 | ```elixir 502 | test "cannot remove last admin from organization" do 503 | org = insert(:organization) 504 | admin = insert(:user) 505 | 506 | membership = insert(:organization_membership, user: admin, organization: org, role: "admin") 507 | 508 | assert {:error, :last_admin} = Accounts.update_membership( 509 | Scope.for_user(admin) |> Scope.put_organization(org), 510 | membership, 511 | %{role: "member"} 512 | ) 513 | end 514 | ``` 515 | 516 | - [ ] [TEST] [P3] Create test helpers for organizations and memberships fixtures 517 | - [ ] [TEST] [P3] Write integration tests for UI flows including organization switching 518 | - [ ] [TEST] [P3] Test multi-org data isolation: 519 | 520 | ```elixir 521 | test "users can only access data in their organizations" do 522 | # Create two organizations 523 | org1 = insert(:organization) 524 | org2 = insert(:organization) 525 | 526 | # Create a user with membership in org1 but not org2 527 | user = insert(:user) 528 | insert(:organization_membership, user: user, organization: org1, role: "member") 529 | 530 | # Create data in both orgs 531 | project1 = insert(:project, organization: org1, name: "Org1 Project") 532 | project2 = insert(:project, organization: org2, name: "Org2 Project") 533 | 534 | # Set up scope for user+org1 535 | scope = Scope.for_user(user) |> Scope.put_organization(org1) 536 | 537 | # Assert user can only see data from org1 538 | projects = Projects.list_projects(scope) 539 | assert length(projects) == 1 540 | assert hd(projects).id == project1.id 541 | end 542 | ``` 543 | 544 | - [ ] [TEST] [P3] Test invitation flow: 545 | 546 | ```elixir 547 | test "invitation tokens are secure and expire properly" do 548 | # Test token generation, email sending, expiration, etc. 549 | end 550 | 551 | test "accepting invitation creates membership with correct role" do 552 | # Test the full invitation acceptance flow 553 | end 554 | ``` 555 | 556 | ## 13. UI/UX Polishing 557 | 558 | _See "LiveView 1.0 Integration and UI Implementation" section in authorization_strategy.md_ 559 | 560 | - [ ] [FE] [P3] Display current organization in navigation/header 561 | - [ ] [FE] [P3] Add organization switcher dropdown in header 562 | - [ ] [FE] [P3] Handle error messages for unauthorized actions 563 | - [ ] [FE] [P3] Create admin panel for managing members and roles 564 | - [ ] [FE] [P3] Add proper feedback for actions (flash messages, etc.) 565 | - [ ] [FE] [P3] Implement clean organization onboarding flow 566 | - [ ] [FE] [P3] Add organization-specific branding options (optional): 567 | - [ ] Custom logo per organization 568 | - [ ] Organization-specific color theme 569 | - [ ] [FULL] [P3] Implement organization onboarding wizard 570 | -------------------------------------------------------------------------------- /single_org/authorization_strategy.md: -------------------------------------------------------------------------------- 1 | # Multi-Tenant Phoenix 1.8 Guide: Single-Organization Model with Role-Based Authorization 2 | 3 | ## Overview of Multi-Tenancy with Single-Organization-Per-User Approach 4 | 5 | Multi-tenancy means a single application instance serves multiple tenants (e.g. organizations or teams), isolating each tenant's data. In our single-organization-per-user shared schema approach, all tenants share the same database tables, with each user belonging to exactly one organization at a time. This approach simplifies migrations, code, and UI implementation compared to multi-organization membership models, while still maintaining proper data isolation through organization_id foreign keys on records. Phoenix 1.8's new Scopes feature helps enforce that all database operations are properly scoped to the current tenant/user by default. 6 | 7 | In this guide, we will build a multi-tenant system using Phoenix 1.8 with the single-organization model, covering these components: 8 | 9 | - **Tenants (Organizations)**: Companies or groups using the app. All data rows are tagged with an organization ID. 10 | - **Users**: Individuals who belong to exactly one organization. Authentication is handled at the user level, with organization context determined by the user's organization_id field. 11 | - **Roles**: Global roles (e.g. Admin, Manager, Member) assigned directly to users within their single organization context. 12 | - **Permissions**: The allowed actions for each role. Enforced via an authorization library (Bodyguard) based on the user's role within their organization. 13 | 14 | By the end, you will have a clear architecture for organizing tenants and users, configuring Phoenix authentication with single-organization support, and enforcing role-based access control on top of Phoenix LiveView 1.0. 15 | 16 | ## System Architecture: Tenants, Users, Roles, Permissions 17 | 18 | **Tenants (Organizations)**: Each tenant is represented by an Organization record. An organization will have many users through a direct has_many relationship. We use a single organizations table for all tenants, with fields like name and a unique slug (for use in URLs). 19 | 20 | **Users**: A user belongs to exactly one organization at a time. This is implemented by placing an organization_id directly on the users table, along with a role field. Users are stored in a shared users table with fields such as email, hashed_password, etc., as generated by phx.gen.auth. 21 | 22 | **Roles**: Roles are defined globally (not created per tenant). For example, we have roles like :admin, :manager, and :member that apply to all organizations. Each user has a single role within their organization. Typical role semantics are: 23 | 24 | - Admin: Full access within their organization (can manage org settings, manage users, etc.). 25 | - Manager: Limited management (e.g. can manage certain resources but not org-level settings). 26 | - Member: Regular user who can only access their own data or non-administrative features. 27 | - System Administrator: A special role that transcends organization boundaries, with privileges across all organizations in the system. 28 | 29 | **Permissions**: Permissions are the actions allowed or denied based on role. Rather than define a complex permission matrix per tenant, we implement permission checks in code (using Bodyguard policies) that consider the user's role within their organization. For example, an Admin user can access all records in their org, whereas a Member might only access records they own. These rules are encoded in policy modules. 30 | 31 | **Data Isolation**: With a shared schema, every relevant table (domain-specific tables like projects, posts, etc.) includes an org_id foreign key referencing the organization. All queries filter by org_id to fetch only data for the current tenant. Phoenix 1.8 scopes help pass the current org_id (and user) around so that context functions automatically enforce this isolation. 32 | 33 | Below is a high-level relationship summary: 34 | 35 | - Organization - has many Users 36 | - User - belongs to one Organization, includes role field 37 | - Role - stored directly on the user (user.role) 38 | 39 | ## System Administrator Role 40 | 41 | In addition to organization-specific roles, the application includes a special **System Administrator** (sysadmin) role that transcends organization boundaries. Unlike regular roles which are scoped to a single organization, sysadmins have privileges that span across all organizations in the system. 42 | 43 | ### Sysadmin Implementation 44 | 45 | The sysadmin role is implemented as a boolean field on the User schema: 46 | 47 | ```elixir 48 | schema "users" do 49 | # ... other fields 50 | field :is_sysadmin, :boolean, default: false 51 | # ... 52 | end 53 | ``` 54 | 55 | This approach allows for: 56 | 57 | 1. **Cross-Organization Access**: Sysadmins can access and manage any organization in the system 58 | 2. **System-Wide Administration**: Perform actions that affect the entire application 59 | 3. **User Management**: Promote or demote other users to/from sysadmin status 60 | 61 | ### Authorization for Sysadmins 62 | 63 | The Bodyguard policy is updated to check for sysadmin status before checking organization-specific permissions: 64 | 65 | ```elixir 66 | defmodule Catalyst.Accounts.Policy do 67 | @behaviour Bodyguard.Policy 68 | 69 | alias Catalyst.Accounts.{User, Organization} 70 | 71 | # Sysadmins can perform any action on any organization 72 | def authorize(_action, %User{is_sysadmin: true}, _params), do: :ok 73 | 74 | # Regular organization-specific rules 75 | def authorize(:update_organization, %User{role: "admin", organization_id: org_id}, %Organization{id: org_id}), do: :ok 76 | # ... other rules 77 | 78 | # Fallback 79 | def authorize(_action, _user, _params), do: :error 80 | end 81 | ``` 82 | 83 | This pattern ensures sysadmins bypass regular permission checks, while maintaining proper authorization for regular users. 84 | 85 | ## Database Schema and Associations 86 | 87 | First, we set up the database tables for organizations and users. We use Ecto migrations and schema definitions in our Phoenix project. 88 | 89 | ### Organizations Migration and Schema 90 | 91 | Create an organizations table with a primary key, name, slug, and any other fields your app needs: 92 | 93 | ```elixir 94 | # priv/repo/migrations/XXXX_create_organizations.exs 95 | 96 | def change do 97 | create table(:organizations) do 98 | add :name, :string, null: false 99 | add :slug, :string, null: false 100 | add :active, :boolean, default: true 101 | timestamps() 102 | end 103 | 104 | create unique_index(:organizations, [:slug]) 105 | end 106 | ``` 107 | 108 | The corresponding Ecto schema: 109 | 110 | ```elixir 111 | # lib/catalyst/accounts/organization.ex 112 | 113 | defmodule Catalyst.Accounts.Organization do 114 | use Ecto.Schema 115 | import Ecto.Changeset 116 | 117 | @derive {Phoenix.Param, key: :slug} 118 | 119 | schema "organizations" do 120 | field :name, :string 121 | field :slug, :string 122 | field :active, :boolean, default: true 123 | 124 | has_many :users, Catalyst.Accounts.User 125 | 126 | timestamps() 127 | end 128 | 129 | def changeset(org, attrs) do 130 | org 131 | |> cast(attrs, [:name, :slug, :active]) 132 | |> validate_required([:name, :slug]) 133 | |> unique_constraint(:slug) 134 | end 135 | end 136 | ``` 137 | 138 | Here, each organization is connected to users through a direct has_many relationship. We use the slug for friendly URLs like /orgs/my-org/... 139 | 140 | ### Users Schema 141 | 142 | The users schema generated by mix phx.gen.auth needs to be updated to include organization_id and role: 143 | 144 | ```elixir 145 | # lib/catalyst/accounts/user.ex 146 | 147 | defmodule Catalyst.Accounts.User do 148 | use Ecto.Schema 149 | import Ecto.Changeset 150 | 151 | @roles ~w(admin manager member)a 152 | 153 | schema "users" do 154 | field :email, :string 155 | field :hashed_password, :string 156 | field :confirmed_at, :utc_datetime 157 | field :role, :string, default: "member" 158 | field :is_sysadmin, :boolean, default: false 159 | # Virtual fields for password (from phx.gen.auth) 160 | field :password, :string, virtual: true 161 | field :current_password, :string, virtual: true 162 | 163 | belongs_to :organization, Catalyst.Accounts.Organization 164 | 165 | timestamps() 166 | end 167 | 168 | # Registration changeset 169 | def registration_changeset(user, attrs) do 170 | user 171 | |> cast(attrs, [:email, :password, :organization_id]) 172 | |> validate_email() 173 | |> validate_password() 174 | |> validate_required([:organization_id]) 175 | end 176 | 177 | # Role changeset 178 | def role_changeset(user, attrs) do 179 | user 180 | |> cast(attrs, [:role]) 181 | |> validate_inclusion(:role, Enum.map(@roles, &Atom.to_string/1)) 182 | end 183 | 184 | # Helper function for guard clauses 185 | def is_sysadmin?(%__MODULE__{is_sysadmin: true}), do: true 186 | def is_sysadmin?(_), do: false 187 | 188 | # Guard macro for use in function guards 189 | defguard is_sysadmin(user) when user.is_sysadmin == true 190 | 191 | # ... (phx.gen.auth typically also provides functions like validate_email, validate_password) 192 | end 193 | ``` 194 | 195 | ### Roles Definition 196 | 197 | Since roles are global, we define them in the User schema as shown above. We use an @roles module attribute to list the allowed roles, which we can reference throughout the application. For simplicity, we're using string values for roles, but you could also use Ecto.Enum if preferred. 198 | 199 | With our schemas in place, let's integrate Phoenix authentication and ensure it's tenant-aware. 200 | 201 | ## Authentication with phx.gen.auth for Single-Organization Support 202 | 203 | Phoenix 1.8 provides the mix phx.gen.auth generator to scaffold out authentication. When you run this generator, it will by default create an Accounts context, a User schema, and all the necessary routes and LiveViews/controllers for user authentication. 204 | 205 | To generate auth, run (if not already done): 206 | 207 | ```bash 208 | $ mix phx.gen.auth Accounts User users --live 209 | ``` 210 | 211 | After running the generator, perform these modifications to make it single-organization aware: 212 | 213 | - Add organization creation during user registration: When a new user signs up, they typically either: 214 | - Create their first organization (becoming an admin of that org) 215 | - Accept an invitation to join an existing organization 216 | 217 | For the first case (creating a new organization during registration), you would extend the registration form to include organization name and slug fields. Then implement: 218 | 219 | ```elixir 220 | def register_user_with_organization(user_attrs, org_attrs) do 221 | Repo.transaction(fn -> 222 | # Create organization first 223 | {:ok, organization} = create_organization(org_attrs) 224 | 225 | # Create user with organization_id and admin role 226 | {:ok, user} = register_user( 227 | Map.merge(user_attrs, %{ 228 | "organization_id" => organization.id, 229 | "role" => "admin" 230 | }) 231 | ) 232 | 233 | user 234 | end) 235 | end 236 | ``` 237 | 238 | - Handle login flow for single organization: After login, redirect directly to the user's organization dashboard: 239 | 240 | ```elixir 241 | def create(conn, %{"user" => user_params}) do 242 | %{"email" => email, "password" => password} = user_params 243 | 244 | if user = Accounts.get_user_by_email_and_password(email, password) do 245 | conn = log_in_user(conn, user) 246 | 247 | # Check if user has an organization 248 | if user.organization_id do 249 | # User has an organization, fetch it 250 | organization = Accounts.get_organization!(user.organization_id) 251 | # Redirect to organization dashboard 252 | redirect(conn, to: ~p"/orgs/#{organization.slug}/dashboard") 253 | else 254 | # No organization, redirect to create one 255 | redirect(conn, to: ~p"/organizations/new") 256 | end 257 | else 258 | # Handle login failure... 259 | end 260 | end 261 | ``` 262 | 263 | With these changes, our authentication system now supports users belonging to a single organization, with direct redirection to their organization dashboard after login. 264 | 265 | ## Using Phoenix 1.8 Scopes for Tenant-Specific Context and Routing 266 | 267 | Phoenix 1.8 introduces Scopes as a first-class mechanism for multi-tenant data isolation and authorization. A scope is a simple struct that contains information about the current request context (like current user and organization). 268 | 269 | ### Extending the Generated Scope Struct 270 | 271 | After running phx.gen.auth, we need to modify the generated scope to include the organization field and automatically load the user's organization: 272 | 273 | ```elixir 274 | defmodule Catalyst.Accounts.Scope do 275 | alias Catalyst.Accounts.{User, Organization} 276 | defstruct user: nil, organization: nil 277 | 278 | def for_user(%User{} = user) do 279 | # In single-org model, always load the user's organization 280 | org = Catalyst.Repo.get(Organization, user.organization_id) 281 | %__MODULE__{user: user, organization: org} 282 | end 283 | def for_user(nil), do: nil 284 | 285 | # Helper for scenarios where we have an org but no user 286 | def for_organization(%Organization{} = org) do 287 | %__MODULE__{organization: org} 288 | end 289 | 290 | # Helpers for IEx development experience 291 | def for(opts) when is_list(opts) do 292 | cond do 293 | opts[:user] -> 294 | user = get_user(opts[:user]) 295 | for_user(user) 296 | opts[:org] -> 297 | org = get_organization(opts[:org]) 298 | for_organization(org) 299 | end 300 | end 301 | 302 | # Convert various inputs to a user 303 | defp get_user(%User{} = user), do: user 304 | defp get_user(user_id) when is_binary(user_id), do: Catalyst.Repo.get(User, user_id) 305 | 306 | # Convert various inputs to an organization 307 | defp get_organization(%Organization{} = org), do: org 308 | defp get_organization(org_id) when is_binary(org_id), do: Catalyst.Repo.get(Organization, org_id) 309 | defp get_organization(slug) when is_binary(slug), do: Catalyst.Repo.get_by(Organization, slug: slug) 310 | end 311 | ``` 312 | 313 | In this simplified version, the scope automatically loads the user's organization based on their organization_id field. There's no longer a need for membership-related functions. 314 | 315 | ### Verifying Organization Access 316 | 317 | We still need to verify that a user is accessing their own organization when they visit organization-scoped routes: 318 | 319 | ```elixir 320 | # lib/catalyst_web/controllers/user_auth.ex 321 | 322 | def verify_organization_access(conn, _opts) do 323 | if slug = conn.params["org"] do 324 | current_scope = conn.assigns.current_scope 325 | current_user = conn.assigns.current_user 326 | 327 | case Catalyst.Accounts.get_organization_by_slug(slug) do 328 | %Catalyst.Accounts.Organization{id: org_id} = org when org_id == current_user.organization_id -> 329 | # User is accessing their own organization - proceed 330 | conn 331 | %Catalyst.Accounts.Organization{} -> 332 | # User is trying to access an organization they don't belong to 333 | conn 334 | |> put_flash(:error, "You don't have access to that organization") 335 | |> redirect(to: ~p"/") 336 | |> halt() 337 | _ -> 338 | conn 339 | |> put_flash(:error, "Organization not found") 340 | |> redirect(to: ~p"/") 341 | |> halt() 342 | end 343 | else 344 | conn 345 | end 346 | end 347 | ``` 348 | 349 | For LiveViews, create a similar on_mount hook: 350 | 351 | ```elixir 352 | def on_mount(:verify_organization_access, %{"org" => slug}, _session, socket) do 353 | current_user = socket.assigns.current_user 354 | 355 | case Catalyst.Accounts.get_organization_by_slug(slug) do 356 | %Catalyst.Accounts.Organization{id: org_id} when org_id == current_user.organization_id -> 357 | # User is accessing their own organization - proceed 358 | {:cont, socket} 359 | %Catalyst.Accounts.Organization{} -> 360 | # User is trying to access another organization 361 | {:halt, socket 362 | |> put_flash(:error, "You don't have access to that organization") 363 | |> redirect(to: ~p"/")} 364 | _ -> 365 | {:halt, socket 366 | |> put_flash(:error, "Organization not found") 367 | |> redirect(to: ~p"/")} 368 | end 369 | end 370 | ``` 371 | 372 | ### Automatic Scope in Context Functions and Migrations 373 | 374 | Phoenix generators can use the scope to generate context functions that automatically filter by organization. In config/config.exs, configure the organization scope: 375 | 376 | ```elixir 377 | # config/config.exs 378 | 379 | config :catalyst, :scopes, 380 | organization: [ 381 | module: Catalyst.Accounts.Scope, 382 | assign_key: :current_scope, 383 | access_path: [:organization, :id], 384 | route_prefix: "/orgs/:org", 385 | schema_key: :organization_id, 386 | schema_type: :binary_id, 387 | schema_table: :organizations 388 | ] 389 | ``` 390 | 391 | Example context function that uses the scope: 392 | 393 | ```elixir 394 | def list_projects(%Accounts.Scope{} = scope) do 395 | from(proj in Project, 396 | where: proj.organization_id == ^scope.organization.id) 397 | |> Repo.all() 398 | end 399 | ``` 400 | 401 | ## Role-Based Authorization with Bodyguard 402 | 403 | With authentication in place and scopes ensuring tenant data isolation, we implement authorization using Bodyguard to manage what actions each role can perform. 404 | 405 | For the single-organization model, our authorization policies check the user's role directly without needing to look up memberships: 406 | 407 | ```elixir 408 | defmodule Catalyst.Accounts.Policy do 409 | @behaviour Bodyguard.Policy 410 | 411 | alias Catalyst.Accounts.{User, Organization} 412 | 413 | # Sysadmins can perform any action on any organization 414 | def authorize(_action, %User{is_sysadmin: true}, _params), do: :ok 415 | 416 | # Only admins can update organization details 417 | def authorize(:update_organization, %User{role: "admin", organization_id: org_id}, %Organization{id: org_id}) do 418 | :ok 419 | end 420 | 421 | # Only admins can invite new users to their org 422 | def authorize(:invite_user, %User{role: "admin", organization_id: org_id}, %Organization{id: org_id}) do 423 | :ok 424 | end 425 | 426 | # Managers can view member list 427 | def authorize(:view_members, %User{role: role, organization_id: org_id}, %Organization{id: org_id}) 428 | when role in ["admin", "manager"] do 429 | :ok 430 | end 431 | 432 | # Fallback for undefined rules or non-matching patterns 433 | def authorize(_action, _user, _params), do: :error 434 | end 435 | ``` 436 | 437 | This simplified approach checks that: 438 | 1. The user's organization_id matches the resource's organization_id 439 | 2. The user has the appropriate role for the requested action 440 | 441 | ### Enforcing Authorization in Context Functions 442 | 443 | We enforce these rules in our context functions: 444 | 445 | ```elixir 446 | def update_organization(%Scope{user: user} = scope, %Organization{} = org, attrs) do 447 | with :ok <- Bodyguard.permit(__MODULE__, :update_organization, user, org), 448 | {:ok, updated_org} <- do_update_organization(org, attrs) do 449 | {:ok, updated_org} 450 | else 451 | {:error, _} = error -> error 452 | _ -> {:error, :unauthorized} 453 | end 454 | end 455 | ``` 456 | 457 | ### User Role Management 458 | 459 | Since roles are stored directly on the user model, role management becomes simplified: 460 | 461 | ```elixir 462 | def update_user_role(%Scope{} = scope, %User{} = user, new_role) do 463 | with :ok <- authorize_role_update(scope, user, new_role), 464 | :ok <- ensure_not_removing_last_admin(user, new_role), 465 | changeset <- User.role_changeset(user, %{role: new_role}), 466 | {:ok, updated} <- Repo.update(changeset) do 467 | {:ok, updated} 468 | end 469 | end 470 | 471 | defp ensure_not_removing_last_admin(%User{role: "admin", organization_id: org_id}, new_role) 472 | when new_role != "admin" do 473 | # Count admins in this organization 474 | admin_count = Repo.one(from u in User, 475 | where: u.organization_id == ^org_id and u.role == "admin", 476 | select: count()) 477 | if admin_count <= 1, do: {:error, :last_admin}, else: :ok 478 | end 479 | defp ensure_not_removing_last_admin(_, _), do: :ok 480 | ``` 481 | 482 | ## LiveView UI Implementation 483 | 484 | Modern Phoenix encourages building interactive UI with LiveView. For our single-organization system: 485 | 486 | ### Conditional UI by Role 487 | 488 | With user.role directly available, we show or hide UI elements based on the user's role: 489 | 490 | ```heex 491 |
492 |

Team Members

493 | 494 | <%= if @current_user.role == "admin" do %> 495 | <.button navigate={~p"/orgs/#{@current_org.slug}/invitations/new"}> 496 | Invite New Member 497 | 498 | <% end %> 499 |
500 | 501 |
502 | 527 |
528 | ``` 529 | 530 | ### Invitation Handling 531 | 532 | For the single-organization model, invitation handling needs careful consideration since accepting an invitation would mean leaving the user's current organization: 533 | 534 | ```elixir 535 | def accept_invitation(token) do 536 | with {:ok, invitation} <- get_valid_invitation(token), 537 | {:ok, user} <- get_or_create_user(invitation) do 538 | 539 | # If user already belongs to another organization, 540 | # we need to move them to the new one 541 | if user.organization_id && user.organization_id != invitation.organization_id do 542 | # Update the user to join the new organization with the invited role 543 | user 544 | |> User.organization_changeset(%{ 545 | organization_id: invitation.organization_id, 546 | role: invitation.role 547 | }) 548 | |> Repo.update() 549 | else 550 | # First-time organization assignment 551 | user 552 | |> User.organization_changeset(%{ 553 | organization_id: invitation.organization_id, 554 | role: invitation.role 555 | }) 556 | |> Repo.update() 557 | end 558 | |> case do 559 | {:ok, updated_user} -> 560 | mark_invitation_accepted(invitation) 561 | {:ok, updated_user} 562 | error -> 563 | error 564 | end 565 | end 566 | end 567 | ``` 568 | 569 | ## Development Phases Checklist 570 | 571 | Here's a concise checklist for implementing the single-organization multi-tenant system: 572 | 573 | 1. **Database Setup**: 574 | - Create organizations table with name, slug, etc. 575 | - Add organization_id and role directly to the users table 576 | - Add is_sysadmin boolean field to users table for cross-organization admin functionality 577 | 578 | 2. **Schema Definitions**: 579 | - Define Organization schema with has_many relationship to users 580 | - Update User schema with belongs_to relationship to organization and role field 581 | 582 | 3. **Authentication Integration**: 583 | - Modify registration flow to create or join an organization 584 | - Update login flow to redirect to the user's organization 585 | 586 | 4. **Configure Phoenix Scopes**: 587 | - Extend Scope to automatically include the user's organization 588 | - Set up organization scope configuration 589 | 590 | 5. **Authorization (Bodyguard) Setup**: 591 | - Implement policies using user.role directly 592 | - Add sysadmin checks as a first rule in policies 593 | 594 | 6. **Context Functions with Scope**: 595 | - Generate organization and domain-specific context functions 596 | - Add role management with "last admin" protection 597 | 598 | 7. **UI Implementation**: 599 | - Create organization settings and user management LiveViews 600 | - Add conditional UI elements based on user's role 601 | - Implement invitation workflow with clear organization transfer warnings 602 | 603 | 8. **Testing**: 604 | - Test data isolation between organizations 605 | - Test role-based access control 606 | - Test sysadmin functionality 607 | 608 | By following these steps, you will have a secure, maintainable foundation for your single-organization multi-tenant system. 609 | -------------------------------------------------------------------------------- /multi_org/authorization_strategy.md: -------------------------------------------------------------------------------- 1 | # Multi-Tenant Phoenix 1.8 Guide: Shared Schema, Scoped Auth, and Role-Based Authorization 2 | 3 | ## Overview of Multi-Tenancy and Shared Schema Approach 4 | 5 | Multi-tenancy means a single application instance serves multiple tenants (e.g. organizations or teams), isolating each tenant's data. In a shared schema approach, all tenants share the same database tables, distinguished by a tenant identifier (such as an organization_id foreign key on records). This approach simplifies migrations and code (versus maintaining separate schemas or databases per tenant) but requires vigilant scoping of queries to avoid data leakage between tenants. Phoenix 1.8's new Scopes feature is designed to help enforce that all database operations are properly scoped to the current tenant/user by default. 6 | 7 | In this guide, we will build a multi-tenant system using Phoenix 1.8 with a shared schema, covering these components: 8 | 9 | - **Tenants (Organizations)**: Companies or groups using the app. All data rows are tagged with an organization ID. 10 | - **Users**: Individuals who belong to multiple organizations simultaneously. Authentication is handled at the user level, with organization context determined after login. 11 | - **Roles**: Global roles (e.g. Admin, Manager, Member) that define what actions a user can perform. Roles are assigned to users in the context of each organization via a membership. 12 | - **Permissions**: The allowed actions for each role. Enforced via an authorization library (Bodyguard) in context of the current user + org. 13 | 14 | By the end, you will have a clear architecture for organizing tenants and users, configuring Phoenix authentication with multi-organization support, and enforcing role-based access control on top of Phoenix LiveView 1.0. 15 | 16 | ## System Architecture: Tenants, Users, Roles, Permissions 17 | 18 | **Tenants (Organizations)**: Each tenant is represented by an Organization record. An organization will have many users through memberships. We use a single organizations table for all tenants, with fields like name and a unique slug (for use in URLs or subdomains). 19 | 20 | **Users**: A user can belong to multiple organizations simultaneously. This is implemented through a join table (OrganizationMembership) rather than placing an organization_id directly on the users table. Users are stored in a shared users table with fields such as email, hashed_password, etc., as generated by phx.gen.auth. 21 | 22 | **Organization Memberships**: The core of our multi-organization approach is the organization_memberships join table connecting users to organizations. Each membership record includes: 23 | 24 | - user_id: References the user 25 | - organization_id: References the organization 26 | - role: The user's role within that specific organization 27 | 28 | This approach allows a user to have different roles in different organizations (e.g., admin in one org, member in another). 29 | 30 | **Roles**: Roles are defined globally (not created per tenant). For example, we might have roles like :admin, :manager, and :member that apply to all organizations. This means the set of roles is fixed application-wide, but each user can have different role assignments in different organizations via their membership records. Typical role semantics could be: 31 | 32 | - Admin: Full access within their organization (can manage org settings, manage users, etc.). 33 | - Manager: Limited management (e.g. can manage certain resources but not org-level settings). 34 | - Member: Regular user who can only access their own data or non-administrative features. 35 | 36 | **Permissions**: Permissions are the actions allowed or denied based on role. Rather than define a complex permission matrix per tenant, we implement permission checks in code (using Bodyguard policies) that consider the user's role within the current organization context. For example, an Admin user can access all records in their org, whereas a Member might only access records they own. These rules will be encoded in policy modules. 37 | 38 | **Data Isolation**: With a shared schema, every relevant table (domain-specific tables like projects, posts, etc.) includes an org_id foreign key referencing the organization. All queries will filter by org_id to fetch only data for the current tenant. Phoenix 1.8 scopes help pass the current org_id (and user) around so that context functions automatically enforce this isolation. This prevents the #1 OWASP vulnerability, broken access control, by ensuring queries/inserts/updates always stay within the proper tenant. 39 | 40 | Below is a high-level relationship summary: 41 | 42 | - Organization - has many Users through OrganizationMemberships 43 | - User - has many Organizations through OrganizationMemberships 44 | - OrganizationMembership - belongs to User and Organization, includes role field 45 | - Role - a global set of roles; each membership record includes a role value 46 | 47 | With the conceptual overview in place, let's move on to implementing the database schema and associations. 48 | 49 | ## Database Schema and Associations 50 | 51 | First, we set up the database tables for organizations, users, and organization memberships. We use Ecto migrations and schema definitions in our Phoenix project. 52 | 53 | ### Organizations Migration and Schema 54 | 55 | Create an organizations table with a primary key, name, slug, and any other fields your app needs (e.g., industry, inserted_at/updated_at timestamps). Ensure the slug or name is unique if you plan to use it in URLs or subdomains. 56 | 57 | ```elixir 58 | # priv/repo/migrations/XXXX_create_organizations.exs 59 | 60 | def change do 61 | create table(:organizations) do 62 | add :name, :string, null: false 63 | add :slug, :string, null: false 64 | add :active, :boolean, default: true 65 | timestamps() 66 | end 67 | 68 | create unique_index(:organizations, [:slug]) 69 | end 70 | ``` 71 | 72 | The corresponding Ecto schema: 73 | 74 | ```elixir 75 | # lib/catalyst/accounts/organization.ex 76 | 77 | defmodule Catalyst.Accounts.Organization do 78 | use Ecto.Schema 79 | import Ecto.Changeset 80 | 81 | @derive {Phoenix.Param, key: :slug} 82 | 83 | schema "organizations" do 84 | field :name, :string 85 | field :slug, :string 86 | field :active, :boolean, default: true 87 | 88 | many_to_many :users, Catalyst.Accounts.User, join_through: Catalyst.Accounts.OrganizationMembership 89 | 90 | timestamps() 91 | end 92 | 93 | def changeset(org, attrs) do 94 | org 95 | |> cast(attrs, [:name, :slug, :active]) 96 | |> validate_required([:name, :slug]) 97 | |> unique_constraint(:slug) 98 | end 99 | end 100 | ``` 101 | 102 | Here, each organization is connected to users through a many-to-many relationship using the organization_memberships join table. We will use the slug for friendly URLs like /orgs/my-org/... and to look up the org in requests. 103 | 104 | ### Organization Memberships Migration and Schema 105 | 106 | We need to create a join table to manage the many-to-many relationship between users and organizations, including the role information: 107 | 108 | ```elixir 109 | # priv/repo/migrations/XXXX_create_organization_memberships.exs 110 | 111 | def change do 112 | create table(:organization_memberships) do 113 | add :user_id, references(:users, on_delete: :delete_all), null: false 114 | add :organization_id, references(:organizations, on_delete: :delete_all), null: false 115 | add :role, :string, null: false, default: "member" 116 | timestamps() 117 | end 118 | 119 | create unique_index(:organization_memberships, [:user_id, :organization_id]) 120 | create index(:organization_memberships, [:organization_id]) 121 | create index(:organization_memberships, [:user_id]) 122 | end 123 | ``` 124 | 125 | The corresponding Ecto schema: 126 | 127 | ```elixir 128 | # lib/catalyst/accounts/organization_membership.ex 129 | 130 | defmodule Catalyst.Accounts.OrganizationMembership do 131 | use Ecto.Schema 132 | import Ecto.Changeset 133 | 134 | @roles ~w(admin manager member)a 135 | 136 | schema "organization_memberships" do 137 | field :role, :string, default: "member" 138 | 139 | belongs_to :user, Catalyst.Accounts.User 140 | belongs_to :organization, Catalyst.Accounts.Organization 141 | 142 | timestamps() 143 | end 144 | 145 | def changeset(membership, attrs) do 146 | membership 147 | |> cast(attrs, [:role, :user_id, :organization_id]) 148 | |> validate_required([:role, :user_id, :organization_id]) 149 | |> validate_inclusion(:role, Enum.map(@roles, &Atom.to_string/1)) 150 | |> unique_constraint([:user_id, :organization_id]) 151 | end 152 | end 153 | ``` 154 | 155 | ### Users Schema 156 | 157 | The users schema generated by mix phx.gen.auth needs to be updated to reflect the many-to-many relationship with organizations: 158 | 159 | ```elixir 160 | # lib/catalyst/accounts/user.ex 161 | 162 | defmodule Catalyst.Accounts.User do 163 | use Ecto.Schema 164 | import Ecto.Changeset 165 | 166 | schema "users" do 167 | field :email, :string 168 | field :hashed_password, :string 169 | field :confirmed_at, :utc_datetime 170 | # Virtual fields for password (from phx.gen.auth) 171 | field :password, :string, virtual: true 172 | field :current_password, :string, virtual: true 173 | 174 | many_to_many :organizations, Catalyst.Accounts.Organization, join_through: Catalyst.Accounts.OrganizationMembership 175 | has_many :organization_memberships, Catalyst.Accounts.OrganizationMembership 176 | 177 | timestamps() 178 | end 179 | 180 | # Registration changeset 181 | def registration_changeset(user, attrs) do 182 | user 183 | |> cast(attrs, [:email, :password]) 184 | |> validate_email() 185 | |> validate_password() 186 | end 187 | 188 | # ... (phx.gen.auth typically also provides functions like validate_email, validate_password) 189 | end 190 | ``` 191 | 192 | ### Roles Definition 193 | 194 | Since roles are global, we define them in the OrganizationMembership schema as shown above. We use an @roles module attribute to list the allowed roles, which we can reference throughout the application. For simplicity, we're using string values for roles, but you could also use Ecto.Enum if preferred. 195 | 196 | With our schemas in place, let's integrate Phoenix authentication and ensure it's tenant-aware. 197 | 198 | ## Authentication with phx.gen.auth for Multi-Organization Support 199 | 200 | Phoenix 1.8 provides the mix phx.gen.auth generator to scaffold out authentication (registration, login, password reset, etc.). When you run this generator, it will by default create an Accounts context, a User schema, and all the necessary routes and LiveViews/controllers for user authentication. Importantly, Phoenix 1.8's auth generator also sets up a default Scope for the user. 201 | 202 | To generate auth, run (if not already done): 203 | 204 | ```bash 205 | $ mix phx.gen.auth Accounts User users --live 206 | ``` 207 | 208 | This creates files like lib/catalyst/accounts/user.ex, user_auth.ex, and related LiveViews or controllers for authentication. It will inject routes under the :browser pipeline for registration, login, etc. 209 | 210 | After running the generator, perform these modifications to make it multi-organization aware: 211 | 212 | - Add organization creation during user registration: When a new user signs up, they typically either: 213 | - Create their first organization (becoming an admin of that org) 214 | - Accept an invitation to join an existing organization 215 | 216 | For the first case (creating a new organization during registration), you would extend the registration form (in the generated user_registration_live.ex) to include organization name and slug fields. Then modify the registration action: 217 | 218 | ```elixir 219 | def handle_event("save", %{"user" => user_params, "organization" => org_params}, socket) do 220 | case Accounts.register_user_with_organization(user_params, org_params) do 221 | {:ok, user} -> 222 | # Handle successful registration... 223 | {:error, %Ecto.Changeset{} = changeset} -> 224 | # Handle errors... 225 | end 226 | end 227 | ``` 228 | 229 | This means implementing a new Accounts function to handle user registration with organization creation: 230 | 231 | ```elixir 232 | def register_user_with_organization(user_attrs, org_attrs) do 233 | Repo.transaction(fn -> 234 | # Create user 235 | {:ok, user} = register_user(user_attrs) 236 | 237 | # Create organization 238 | {:ok, organization} = create_organization(org_attrs) 239 | 240 | # Create membership with admin role 241 | {:ok, _membership} = create_membership(%{ 242 | user_id: user.id, 243 | organization_id: organization.id, 244 | role: "admin" 245 | }) 246 | 247 | user 248 | end) 249 | end 250 | ``` 251 | 252 | - Add organization selection after login: Since users can belong to multiple organizations, after login you'll need to: 253 | 254 | 1. Fetch all organizations the user belongs to 255 | 2. Either redirect to the last used organization, prompt for selection, or redirect to create a new organization if none exist 256 | 257 | This would be handled in the user_session_controller.ex or user_session_live.ex created by phx.gen.auth: 258 | 259 | ```elixir 260 | def create(conn, %{"user" => user_params}) do 261 | %{"email" => email, "password" => password} = user_params 262 | 263 | if user = Accounts.get_user_by_email_and_password(email, password) do 264 | conn = log_in_user(conn, user) 265 | 266 | # Fetch user's organizations 267 | organizations = Accounts.list_user_organizations(user) 268 | 269 | case organizations do 270 | [] -> 271 | # No organizations, redirect to create one 272 | redirect(conn, to: ~p"/organizations/new") 273 | [organization] -> 274 | # Only one organization, redirect directly 275 | redirect(conn, to: ~p"/orgs/#{organization.slug}/dashboard") 276 | _multiple -> 277 | # Multiple organizations, let user choose 278 | redirect(conn, to: ~p"/organizations") 279 | end 280 | else 281 | # Handle login failure... 282 | end 283 | end 284 | ``` 285 | 286 | - Extend Scope for organization context: We'll need to modify the generated Scope struct to support the current organization and membership context, but that's covered in the next section. 287 | 288 | With these changes, our authentication system now supports users belonging to multiple organizations, with organization selection happening after successful login. 289 | 290 | ## Using Phoenix 1.8 Scopes for Tenant-Specific Context and Routing 291 | 292 | Phoenix 1.8 introduces Scopes as a first-class mechanism for multi-tenant data isolation and authorization. A scope is a simple struct that contains information about the current request context (like current user, and now, current organization). By passing this struct to all your context functions, you ensure those functions can filter data appropriately. The Phoenix generators automatically utilize the scope in generated code when a default scope is configured. 293 | 294 | ### Extending the Generated Scope Struct 295 | 296 | After running phx.gen.auth, you will have a scope module (e.g. lib/catalyst/accounts/scope.ex) similar to: 297 | 298 | ```elixir 299 | defmodule Catalyst.Accounts.Scope do 300 | alias Catalyst.Accounts.User 301 | defstruct user: nil 302 | 303 | def for_user(%User{} = user), do: %__MODULE__{user: user} 304 | def for_user(nil), do: nil 305 | end 306 | ``` 307 | 308 | We need to include the organization and membership in this scope. We need to add both organization and membership fields to the struct and provide helpers to set them: 309 | 310 | ```elixir 311 | defmodule Catalyst.Accounts.Scope do 312 | alias Catalyst.Accounts.{User, Organization, OrganizationMembership} 313 | defstruct user: nil, organization: nil, membership: nil 314 | 315 | def for_user(%User{} = user) do 316 | %__MODULE__{user: user} 317 | end 318 | def for_user(nil), do: nil 319 | 320 | # Put an organization into an existing scope and find the associated membership 321 | def put_organization(%__MODULE__{user: user} = scope, %Organization{} = org) do 322 | membership = get_membership(user, org) 323 | %{scope | organization: org, membership: membership} 324 | end 325 | 326 | # Add a helper to directly set membership 327 | def put_membership(%__MODULE__{} = scope, %OrganizationMembership{} = membership) do 328 | %{scope | membership: membership} 329 | end 330 | 331 | # Find membership between user and organization 332 | defp get_membership(%User{id: user_id}, %Organization{id: org_id}) do 333 | Catalyst.Repo.get_by(OrganizationMembership, user_id: user_id, organization_id: org_id) 334 | end 335 | end 336 | ``` 337 | 338 | Now the scope can carry the current user, their organization, and the specific membership that connects them (which includes the user's role in that organization). The for_user/1 function no longer sets an organization automatically since a user can belong to multiple organizations. 339 | 340 | ### Assigning Current Organization in the Router 341 | 342 | Phoenix 1.8's auth generator sets up a plug :fetch_current_scope_for_user in the browser pipeline that populates conn.assigns.current_scope with the user scope. We will add another plug to find the organization from the request and attach it to the scope. There are two common patterns for identifying the current org in requests: 343 | 344 | - Subdomain: e.g. tenant.catalyst.com → parse subdomain to find org. 345 | - Path segment: e.g. catalyst.com/orgs/:org_slug/... → parse URL param. 346 | 347 | For illustration, we'll use the path approach with an :org URL param (slug). Update your router pipeline and add an assign_org_to_scope plug: 348 | 349 | ```elixir 350 | # lib/catalyst_web/router.ex 351 | 352 | pipeline :browser do 353 | # ... 354 | plug :fetch_current_user # from phx.gen.auth (fetches current_user if any) 355 | plug :fetch_current_scope_for_user # from phx.gen.auth (assigns current_scope with user) 356 | plug :assign_org_to_scope # our custom plug 357 | end 358 | ``` 359 | 360 | Define the plug in your CatalystWeb.UserAuth module (which already contains auth plugs): 361 | 362 | ```elixir 363 | # lib/catalyst_web/controllers/user_auth.ex 364 | 365 | def assign_org_to_scope(conn, _opts) do 366 | if slug = conn.params["org"] do 367 | current_scope = conn.assigns.current_scope 368 | case Catalyst.Accounts.get_organization_by_slug(slug) do 369 | %Catalyst.Accounts.Organization{} = org -> 370 | # Ensure user has membership in this organization 371 | if membership = Catalyst.Accounts.get_membership(current_scope.user, org) do 372 | assign(conn, :current_scope, 373 | Catalyst.Accounts.Scope.put_organization(current_scope, org)) 374 | else 375 | conn 376 | |> put_flash(:error, "You don't have access to that organization") 377 | |> redirect(to: ~p"/organizations") 378 | |> halt() 379 | end 380 | _ -> 381 | conn 382 | |> put_flash(:error, "Organization not found") 383 | |> redirect(to: ~p"/organizations") 384 | |> halt() 385 | end 386 | else 387 | conn 388 | end 389 | end 390 | ``` 391 | 392 | Here we look up the organization by the slug from the URL. If the organization exists, we verify the user has a membership in it. If both checks pass, we update the scope with both the organization and the user's membership in that organization. If either fails, we redirect with an error message. This ensures a user can only access organizations they are a member of. 393 | 394 | LiveView considerations: For LiveViews, the above plug runs only on the initial HTTP request to mount the LiveView. To ensure the scope (with org) is available on mounting and live navigation, Phoenix provides an on_mount hook in the generator. We need to add our own for org. For example, in user_auth.ex we can add: 395 | 396 | ```elixir 397 | def on_mount(:assign_org_to_scope, %{"org" => slug}, _session, socket) do 398 | current_scope = socket.assigns.current_scope 399 | 400 | case Catalyst.Accounts.get_organization_by_slug!(slug) do 401 | %Catalyst.Accounts.Organization{} = org -> 402 | # Ensure user has membership in this organization 403 | if membership = Catalyst.Accounts.get_membership(current_scope.user, org) do 404 | new_scope = current_scope 405 | |> Catalyst.Accounts.Scope.put_organization(org) 406 | |> Catalyst.Accounts.Scope.put_membership(membership) 407 | 408 | {:cont, Phoenix.Component.assign(socket, :current_scope, new_scope)} 409 | else 410 | {:halt, socket 411 | |> put_flash(:error, "You don't have access to that organization") 412 | |> redirect(to: ~p"/organizations")} 413 | end 414 | _ -> 415 | {:halt, socket 416 | |> put_flash(:error, "Organization not found") 417 | |> redirect(to: ~p"/organizations")} 418 | end 419 | end 420 | 421 | def on_mount(:assign_org_to_scope, _params, _session, socket) do 422 | {:cont, socket} 423 | end 424 | ``` 425 | 426 | We will use this in our LiveView routing next. (Notice we used a bang version get_organization_by_slug! which will raise Ecto.NoResultsError if not found, automatically translating to a 404 if not caught.) 427 | 428 | ### Scoped Routes with Organization Prefix 429 | 430 | Now, define your application routes to include the organization context. Using Phoenix LiveView's live_session is a good way to ensure certain mounts have the necessary on_mount hooks. For example: 431 | 432 | ```elixir 433 | scope "/", CatalystWeb do 434 | pipe_through [:browser, :require_authenticated_user] 435 | 436 | live_session :org_app, on_mount: [ 437 | {CatalystWeb.UserAuth, :ensure_authenticated}, 438 | {CatalystWeb.UserAuth, :mount_current_scope}, 439 | {CatalystWeb.UserAuth, :assign_org_to_scope} 440 | ] do 441 | live "/orgs/:org/dashboard", DashboardLive, :index 442 | live "/orgs/:org/projects", ProjectLive.Index, :index 443 | live "/orgs/:org/projects/new", ProjectLive.Form, :new 444 | live "/orgs/:org/projects/:id", ProjectLive.Show, :show 445 | # ... other org-scoped live routes 446 | end 447 | end 448 | ``` 449 | 450 | In the above: 451 | 452 | - We nest routes under /orgs/:org/ where :org is the organization slug. 453 | - We require authentication and mount the current user scope (mount_current_scope hook provided by auth generator) and then our assign_org_to_scope on each LiveView in this session. This guarantees socket.assigns.current_scope has both user and org for all LiveViews under this route. 454 | - Controllers (if any) under this scope would also get the org via the plug in the pipeline. 455 | 456 | Now all incoming requests under /orgs/:org/... will have conn.assigns.current_scope containing the user, organization, and membership. Phoenix contexts can leverage this scope. 457 | 458 | ### Automatic Scope in Context Functions and Migrations 459 | 460 | Phoenix generators can use the scope to generate context functions that automatically filter by org. In config/config.exs, set up scope configurations. Since phx.gen.auth already set up a default :user scope, we'll augment with an :organization scope for resource generators: 461 | 462 | ```elixir 463 | # config/config.exs 464 | 465 | config :catalyst, :scopes, 466 | user: [ 467 | default: true, 468 | module: Catalyst.Accounts.Scope, 469 | assign_key: :current_scope, 470 | access_path: [:user, :id] # identifies user by user.id 471 | ], 472 | organization: [ 473 | module: Catalyst.Accounts.Scope, 474 | assign_key: :current_scope, 475 | access_path: [:organization, :id], # identifies org by org.id 476 | route_prefix: "/orgs/:org", # nest generated routes under this prefix 477 | schema_key: :organization_id, 478 | schema_type: :binary_id, 479 | schema_table: :organizations 480 | ] 481 | ``` 482 | 483 | This configuration tells Phoenix's code generators how to incorporate the organization scope. For example, if you run mix phx.gen.live Projects Project projects name:string ... --scope organization, it will generate context functions like list_projects(scope) that automatically include where: p.organization_id == ^scope.organization.id in queries, migrations that add organization_id foreign key, and routes prefixed with /orgs/:org. The route_prefix with :org param ties into our router setup, and the access_path [:organization, :id] combined with route_prefix means the generator will use the org slug (since Phoenix.Param protocol on Organization can be set to use slug) for URL helpers and capturing that param. 484 | 485 | Example Context Function: If we had a Project schema with an organization_id field, a generated function might look like: 486 | 487 | ```elixir 488 | def list_projects(%Accounts.Scope{} = scope) do 489 | from(proj in Project, 490 | where: proj.organization_id == ^scope.organization.id) 491 | |> Repo.all() 492 | end 493 | ``` 494 | 495 | All such functions require passing the current scope. You can obtain the scope in controllers as conn.assigns.current_scope and in LiveViews as @current_scope (assigned via our on_mount). This pattern ensures all data access is automatically tenant-filtered, aligning with Phoenix's goal of making secure data access the default. 496 | 497 | ### Context Associations and Preloading 498 | 499 | When working with the multi-organization approach, we need to be careful about preloading associations. Since a user can belong to multiple organizations, we need to ensure we're preloading the right memberships and organizations. 500 | 501 | For instance, when viewing a specific organization, we might want to preload all members: 502 | 503 | ```elixir 504 | def get_organization_with_members!(%Accounts.Scope{} = scope, id) do 505 | Repo.get_by!(Organization, id: id, id: scope.organization.id) 506 | |> Repo.preload([organization_memberships: [user: []]]) 507 | end 508 | ``` 509 | 510 | Similarly, when listing a user's organizations, we would: 511 | 512 | ```elixir 513 | def list_user_organizations(%User{} = user) do 514 | user = Repo.preload(user, :organizations) 515 | user.organizations 516 | end 517 | ``` 518 | 519 | At this stage, we have a robust setup where: 520 | 521 | - Each request knows the current %Organization{}, %User{}, and %OrganizationMembership{} (all in current_scope). 522 | - All queries and resource routes are naturally scoped by org. 523 | - Users can belong to multiple organizations with different roles in each. 524 | 525 | Next, we will enforce authorization rules on top of this using Bodyguard, to manage what roles can do within the tenant. 526 | 527 | ## Role-Based Authorization with Bodyguard 528 | 529 | Authentication (identity verification) is in place, and scopes ensure tenants' data is isolated. Now we layer authorization: determining if a given user can perform a given action. We'll use the Bodyguard library, which provides a convenient way to define and check authorization policies. 530 | 531 | Bodyguard works by defining an authorize/3 callback in your context modules (or dedicated policy modules) that decides if a user (and optionally other params) can perform a certain action. We then call Bodyguard.permit/4 to enforce these rules at runtime. 532 | 533 | ### Defining Roles and Permissions 534 | 535 | We decided on global roles (admin, manager, member) that are assigned at the membership level. This means a user can be an admin in one organization but just a member in another. We'll enforce permissions such as: 536 | 537 | - Org-level actions (e.g. editing organization settings, inviting users) – only Admins (and maybe Managers) of that org can do these. 538 | - Resource actions (e.g. editing a project or post) – perhaps Admin or Manager can edit any within the org, whereas a Member can only edit ones they own. 539 | 540 | Let's codify some rules using Bodyguard. For our multi-organization approach, it's cleaner to implement the Bodyguard.Policy behaviour in dedicated policy modules rather than directly in the context. Here's an example for organization management: 541 | 542 | ```elixir 543 | defmodule Catalyst.Accounts.Policy do 544 | @behaviour Bodyguard.Policy 545 | 546 | alias Catalyst.Accounts.{User, Organization, OrganizationMembership, Scope} 547 | alias Catalyst.Repo 548 | 549 | # Only admins can update organization details 550 | def authorize(:update_organization, %User{} = user, %Organization{} = org) do 551 | case get_membership(user, org) do 552 | %OrganizationMembership{role: "admin"} -> :ok 553 | _ -> :error 554 | end 555 | end 556 | 557 | # Only admins can invite new users to their org 558 | def authorize(:invite_user, %User{} = user, %Organization{} = org) do 559 | case get_membership(user, org) do 560 | %OrganizationMembership{role: "admin"} -> :ok 561 | _ -> :error 562 | end 563 | end 564 | 565 | # Managers can view member list 566 | def authorize(:view_members, %User{} = user, %Organization{} = org) do 567 | case get_membership(user, org) do 568 | %OrganizationMembership{role: role} when role in ["admin", "manager"] -> :ok 569 | _ -> :error 570 | end 571 | end 572 | 573 | # Fallback for undefined rules 574 | def authorize(_action, _user, _params), do: :error 575 | 576 | # Helper to get membership 577 | defp get_membership(%User{id: user_id}, %Organization{id: org_id}) do 578 | Repo.get_by(OrganizationMembership, user_id: user_id, organization_id: org_id) 579 | end 580 | end 581 | ``` 582 | 583 | In the above policy examples: 584 | 585 | - We check the user's role for the specific organization by looking up their membership. 586 | - We return :ok to permit or :error (or {:error, :reason}) to deny. Bodyguard will interpret :error as an unauthorized attempt by default. 587 | 588 | We use this policy module in our context: 589 | 590 | ```elixir 591 | defmodule Catalyst.Accounts do 592 | use Bodyguard.Policy, policy: Catalyst.Accounts.Policy 593 | 594 | # Context functions... 595 | end 596 | ``` 597 | 598 | We can also define policies in other contexts. For example, suppose we have a Projects context for a project management feature: 599 | 600 | ```elixir 601 | defmodule Catalyst.Projects.Policy do 602 | @behaviour Bodyguard.Policy 603 | 604 | alias Catalyst.Accounts.{User, Organization, OrganizationMembership} 605 | alias Catalyst.Projects.Project 606 | alias Catalyst.Repo 607 | 608 | # Admin or Manager can delete any project in their org; Members can delete only their own 609 | def authorize(:delete_project, %User{} = user, %Project{} = project) do 610 | case get_membership(user, %Organization{id: project.organization_id}) do 611 | %OrganizationMembership{role: role} when role in ["admin", "manager"] -> :ok 612 | %OrganizationMembership{} when user.id == project.owner_id -> :ok 613 | _ -> :error 614 | end 615 | end 616 | 617 | # Only Admin can mark project as archived 618 | def authorize(:archive_project, %User{} = user, %Project{} = project) do 619 | case get_membership(user, %Organization{id: project.organization_id}) do 620 | %OrganizationMembership{role: "admin"} -> :ok 621 | _ -> :error 622 | end 623 | end 624 | 625 | def authorize(_action, _user, _params), do: :error 626 | 627 | # Helper to get membership 628 | defp get_membership(%User{id: user_id}, %Organization{id: org_id}) do 629 | Repo.get_by(OrganizationMembership, user_id: user_id, organization_id: org_id) 630 | end 631 | end 632 | 633 | defmodule Catalyst.Projects do 634 | use Bodyguard.Policy, policy: Catalyst.Projects.Policy 635 | 636 | # Context functions... 637 | end 638 | ``` 639 | 640 | This illustrates a mix of role-based and ownership-based rules. In Bodyguard's philosophy, simple pattern matching or guard clauses can express most rules. The example above says: 641 | 642 | - To :delete_project, allow if the user is admin/manager of that org, or if the user is the project owner. 643 | - To :archive_project, only org admins can do it. 644 | 645 | Note how these policy functions explicitly look up the membership for the specific organization-user combination, reinforcing our multi-organization model. A user's permission for an action depends on their role in the organization that owns the resource. 646 | 647 | ### Enforcing Authorization in Controllers and LiveViews 648 | 649 | With policies defined, the next step is to actually enforce them when actions are attempted. Bodyguard provides the Bodyguard.permit/4 function to check authorization and return :ok or an error. There is also Bodyguard.permit!/4 which raises on unauthorized, and a convenience boolean permit?/4. Additionally, Bodyguard offers a plug for controllers and guidance for LiveView usage. 650 | 651 | In Controllers (if using non-LiveView): You might use the Bodyguard.Plug.Authorize in a pipeline or call Bodyguard.permit manually. For example, in a controller action: 652 | 653 | ```elixir 654 | def update(conn, %{"id" => org_id, "organization" => org_params}) do 655 | org = Accounts.get_organization!(conn.assigns.current_scope, org_id) 656 | 657 | # Authorize that current user can update this organization: 658 | with :ok <- Bodyguard.permit(Catalyst.Accounts, :update_organization, conn.assigns.current_scope.user, org), 659 | {:ok, %Organization{} = org} <- Accounts.update_organization(conn.assigns.current_scope, org, org_params) do 660 | redirect(conn, to: ~p"/orgs/#{org.slug}/settings") 661 | else 662 | {:error, :unauthorized} -> 663 | conn |> put_flash(:error, "You are not authorized to do that.") |> redirect(to: ~p"/organizations") 664 | {:error, %Ecto.Changeset{} = changeset} -> 665 | render(conn, :edit, changeset: changeset, organization: org) 666 | end 667 | end 668 | ``` 669 | 670 | We pass Catalyst.Accounts as the module (since we configured it to use our policy module), the action atom, the current user, and the organization struct. If it's not :ok, we handle it appropriately. 671 | 672 | Alternatively, using the plug approach at the controller module level: 673 | 674 | ```elixir 675 | plug Bodyguard.Plug.Authorize, 676 | policy: Catalyst.Accounts, 677 | action: :update_organization, 678 | user: &__MODULE__.current_user/1, 679 | params: &__MODULE__.get_org_from_params/1, 680 | fallback: CatalystWeb.FallbackController 681 | when action in [:update] 682 | ``` 683 | 684 | This example would call a get_org_from_params(conn) function that fetches the org, then Bodyguard will invoke the policy. 685 | 686 | In LiveViews: In LiveView, you typically perform authorization in the mount/3 or in event handlers (handle_event). For instance, in mount, if a user is not authorized to view the page, you can redirect away: 687 | 688 | ```elixir 689 | def mount(params, _session, socket) do 690 | scope = socket.assigns.current_scope 691 | project = Projects.get_project!(scope, params["id"]) 692 | 693 | case Bodyguard.permit(Catalyst.Projects, :view_project, scope.user, project) do 694 | :ok -> 695 | {:ok, assign(socket, project: project)} 696 | {:error, _reason} -> 697 | {:halt, socket 698 | |> put_flash(:error, "You don't have permission to view this project") 699 | |> redirect(to: ~p"/orgs/#{scope.organization.slug}/projects")} 700 | end 701 | end 702 | ``` 703 | 704 | For button actions or forms, in handle_event you can similarly check with Bodyguard.permit before proceeding: 705 | 706 | ```elixir 707 | def handle_event("delete", %{"id" => id}, socket) do 708 | scope = socket.assigns.current_scope 709 | project = Projects.get_project!(scope, id) 710 | 711 | case Bodyguard.permit(Catalyst.Projects, :delete_project, scope.user, project) do 712 | :ok -> 713 | {:ok, _} = Projects.delete_project(scope, project) 714 | {:noreply, socket 715 | |> put_flash(:info, "Project deleted successfully") 716 | |> push_navigate(to: ~p"/orgs/#{scope.organization.slug}/projects")} 717 | _ -> 718 | {:noreply, socket 719 | |> put_flash(:error, "You don't have permission to delete this project")} 720 | end 721 | end 722 | ``` 723 | 724 | Because we have the current scope (with user, organization, and membership) readily available in LiveViews (thanks to on_mount hooks), we can always retrieve this context for authorization checks. 725 | 726 | ### Using Bodyguard Schema for Query Filtering (Optional) 727 | 728 | Bodyguard also provides Bodyguard.Schema for defining query scopes (similar to Phoenix scopes, but at the Ecto level). For example, you could implement c:Bodyguard.Schema.scope/3 on the Project schema to return only projects a given user should see based on role. This could be useful if you had more complex per-user filtering on top of tenant (e.g., an admin can see all within org, a member only their own records). 729 | 730 | For example: 731 | 732 | ```elixir 733 | defmodule Catalyst.Projects.Project do 734 | use Ecto.Schema 735 | use Bodyguard.Schema 736 | 737 | # ... schema definition ... 738 | 739 | def scope(query, %User{} = user, _opts) do 740 | org_id = opts[:organization_id] 741 | membership = Catalyst.Accounts.get_membership(user, %Organization{id: org_id}) 742 | 743 | case membership do 744 | %{role: role} when role in ["admin", "manager"] -> 745 | # Admins and managers see all projects in the org 746 | query 747 | %{role: "member"} -> 748 | # Members only see their own projects or public ones 749 | from p in query, 750 | where: p.owner_id == ^user.id or p.public == true 751 | _ -> 752 | # No membership or unknown role - see nothing 753 | from p in query, where: false 754 | end 755 | end 756 | end 757 | ``` 758 | 759 | However, with our Phoenix Scope approach, this level of filtering can also be handled in the context functions, making the Bodyguard.Schema approach optional. 760 | 761 | In summary, Bodyguard gives us a flexible way to enforce that even within a tenant's data, certain users can or cannot perform specific actions based on their role within that specific organization. We have set up the basic roles and added checks in the appropriate places (controller actions or LiveView events). Next, let's consider how the UI and LiveView components tie it all together, including using Phoenix LiveView 1.0 syntax like to_form for forms. 762 | 763 | ## LiveView 1.0 Integration and UI Implementation 764 | 765 | Modern Phoenix (1.7 and 1.8) encourages building interactive UI with LiveView and function components. Our multi-tenant system will have LiveViews for organization settings, project management, user management, and organization switching. Here we discuss a few integration points: 766 | 767 | - Using the current_scope in LiveView assigns to filter and display data 768 | - Building forms using LiveView 1.0's to_form/2 and the new component-based form API 769 | - Adding UI elements that respect authorization based on the user's role in the current organization 770 | 771 | ### Mounting with Scope 772 | 773 | As shown, we ensure every LiveView relevant to tenant data is wrapped in live_session with :mount_current_user and :assign_org_to_scope. That means in any LiveView module, we can safely use socket.assigns.current_scope which contains user, organization, and membership. For convenience, you might also assign these separately: 774 | 775 | ```elixir 776 | def mount(_params, _session, socket) do 777 | socket = socket 778 | |> assign(current_user: socket.assigns.current_scope.user) 779 | |> assign(current_org: socket.assigns.current_scope.organization) 780 | |> assign(current_membership: socket.assigns.current_scope.membership) 781 | 782 | # Rest of mount logic... 783 | {:ok, socket} 784 | end 785 | ``` 786 | 787 | Now @current_user, @current_org, and @current_membership can be used in the templates directly (for example, to display the org name in a header, or conditionally render admin links if @current_membership.role == "admin"). 788 | 789 | ### Organization Switching 790 | 791 | Since users can belong to multiple organizations, we need to implement a way for them to switch between organizations. This could be a dropdown in the header or a separate page: 792 | 793 | ```elixir 794 | # Organization selector component 795 | def organization_selector(assigns) do 796 | ~H""" 797 | <.dropdown id="org-switcher"> 798 | <:trigger> 799 |
800 | <%= @current_org.name %> 801 | <.icon name="hero-chevron-down" /> 802 |
803 | 804 | 805 | <:content> 806 |
807 | <%= for org <- @user_organizations do %> 808 | <.link 809 | navigate={~p"/orgs/#{org.slug}/dashboard"} 810 | class={"block px-4 py-2 text-sm hover:bg-gray-100 #{if org.id == @current_org.id, do: "bg-gray-100", else: ""}"} 811 | > 812 | <%= org.name %> 813 | 814 | <% end %> 815 |
816 | <.link 817 | navigate={~p"/organizations/new"} 818 | class="block px-4 py-2 text-sm hover:bg-gray-100" 819 | > 820 | Create New Organization 821 | 822 |
823 | 824 | 825 | """ 826 | end 827 | ``` 828 | 829 | You would include this component in your layout, fetching the user's organizations in the layout's on_mount hook: 830 | 831 | ```elixir 832 | def on_mount(:default, _params, _session, socket) do 833 | if socket.assigns[:current_scope] do 834 | user_organizations = Catalyst.Accounts.list_user_organizations(socket.assigns.current_scope.user) 835 | {:cont, assign(socket, user_organizations: user_organizations)} 836 | else 837 | {:cont, socket} 838 | end 839 | end 840 | ``` 841 | 842 | ### Forms with to_form 843 | 844 | Phoenix LiveView 1.0 introduced a streamlined way to work with forms. Instead of using the old form_for helpers, we now convert changesets to a form struct via Phoenix.Component.to_form/1 and use the <.form> function component in HEEx templates. This provides consistent change tracking and integration with LiveView events. For example, in an Organization settings LiveView for editing org info: 845 | 846 | ```elixir 847 | @impl true 848 | def mount(_params, _session, socket) do 849 | org = socket.assigns.current_org 850 | changeset = Catalyst.Accounts.change_organization(org) 851 | {:ok, assign(socket, form: to_form(changeset))} 852 | end 853 | 854 | @impl true 855 | def handle_event("validate", %{"organization" => org_params}, socket) do 856 | changeset = 857 | socket.assigns.current_org 858 | |> Catalyst.Accounts.change_organization(org_params) 859 | |> Map.put(:action, :validate) 860 | 861 | {:noreply, assign(socket, form: to_form(changeset))} 862 | end 863 | 864 | @impl true 865 | def handle_event("save", %{"organization" => org_params}, socket) do 866 | scope = socket.assigns.current_scope 867 | 868 | case Catalyst.Accounts.update_organization(scope, socket.assigns.current_org, org_params) do 869 | {:ok, org} -> 870 | {:noreply, socket 871 | |> put_flash(:info, "Organization updated") 872 | |> push_navigate(to: ~p"/orgs/#{org.slug}/settings")} 873 | {:error, changeset} -> 874 | {:noreply, assign(socket, form: to_form(changeset))} 875 | end 876 | end 877 | ``` 878 | 879 | And the corresponding HEEx template might use Phoenix's core components: 880 | 881 | ```heex 882 |
883 |

Edit Organization

884 | 885 | <.form for={@form} phx-change="validate" phx-submit="save"> 886 | <.input field={@form[:name]} label="Name" /> 887 | <.input field={@form[:slug]} label="Slug" /> 888 | <.input field={@form[:active]} type="checkbox" label="Active?" /> 889 | 890 |
891 | <.button phx-disable-with="Saving...">Save Organization 892 |
893 | 894 |
895 | ``` 896 | 897 | Here .form is a function component that expects an @form assign (our to_form output). Each .input component uses the form field, and Phoenix will handle binding and validations. The changeset's errors will be automatically displayed by <.input> if the component is set up to do so (the default ones from Phoenix include error handling). 898 | 899 | ### Conditional UI by Role 900 | 901 | With @current_membership.role available, your templates can conditionally show admin-only links or buttons. For instance, in a team members list LiveView, you might show an "Invite User" button only for organization admins: 902 | 903 | ```heex 904 |
905 |

Team Members

906 | 907 | <%= if @current_membership.role == "admin" do %> 908 | <.button navigate={~p"/orgs/#{@current_org.slug}/invitations/new"}> 909 | Invite New Member 910 | 911 | <% end %> 912 |
913 | 914 |
915 | 940 |
941 | ``` 942 | 943 | Similarly, within a LiveView event handler, you might double-check authorization before performing an action: 944 | 945 | ```elixir 946 | def handle_event("remove", %{"id" => membership_id}, socket) do 947 | scope = socket.assigns.current_scope 948 | membership = Catalyst.Accounts.get_membership!(membership_id) 949 | 950 | case Bodyguard.permit(Catalyst.Accounts, :remove_member, scope.user, scope.organization) do 951 | :ok -> 952 | case Catalyst.Accounts.delete_membership(scope, membership) do 953 | {:ok, _} -> 954 | memberships = Catalyst.Accounts.list_organization_memberships(scope) 955 | {:noreply, socket 956 | |> put_flash(:info, "Member removed successfully") 957 | |> assign(memberships: memberships)} 958 | {:error, :last_admin} -> 959 | {:noreply, socket |> put_flash(:error, "Cannot remove the last admin")} 960 | end 961 | _ -> 962 | {:noreply, socket |> put_flash(:error, "You don't have permission to remove members")} 963 | end 964 | end 965 | ``` 966 | 967 | Because we have the current scope (with user, organization, and membership) readily available in LiveViews (thanks to on_mount hooks), we can always retrieve this context for authorization checks. 968 | 969 | ### Navigation and Tenant Context 970 | 971 | Ensure all navigation links include the org scope. Use route helpers or the new ~p sigil with the org in path. For example: 972 | 973 | ```heex 974 | 1011 | ``` 1012 | 1013 | This keeps the user in the context of their organization and only shows navigation options appropriate for their role in the current organization. 1014 | 1015 | ## Development Phases Checklist 1016 | 1017 | Here's a checklist of each phase in implementing the multi-tenant system, from database setup to UI integration: 1018 | 1019 | 1. **Database Setup**: 1020 | 1021 | - Create migrations for organizations table with name, slug, etc. 1022 | - Create organization_memberships join table with user_id, organization_id, and role 1023 | - Ensure user schema does NOT have organization_id (no single-org constraint) 1024 | 1025 | 2. **Schema Definitions**: 1026 | 1027 | - Define Organization schema with many_to_many relationship to users 1028 | - Define OrganizationMembership schema with belongs_to relationships 1029 | - Update User schema with many_to_many relationship to organizations 1030 | - Define roles as a constant list (e.g., @roles ~w(admin manager member)a) 1031 | 1032 | 3. **Generate Authentication**: 1033 | 1034 | - Run mix phx.gen.auth Accounts User users --live to scaffold authentication 1035 | - Follow generator instructions to migrate and integrate 1036 | - This gives you user context, schema, and basic sessions management 1037 | 1038 | 4. **Integrate Organization into Auth**: 1039 | 1040 | - Modify registration flow to handle organization creation or selection 1041 | - Extend registration to let new users create their first organization 1042 | - Implement organization invitation system for adding users to existing orgs 1043 | - Adjust login flow to handle multiple organizations per user 1044 | 1045 | 5. **Configure Phoenix Scopes**: 1046 | 1047 | - Extend the generated Accounts.Scope to include organization and membership 1048 | - Add helpers like put_organization/2 and get_membership/2 1049 | - Ensure scope includes access to user's role via membership 1050 | - Update config/config.exs with organization scope configuration 1051 | 1052 | 6. **Router and Plugs**: 1053 | 1054 | - Add assign_org_to_scope plug to browser pipeline 1055 | - Create on_mount callback for LiveViews to handle organization context 1056 | - Define routes nested under /orgs/:org/... with proper live_session setup 1057 | - Add organization switching functionality 1058 | 1059 | 7. **Context Functions with Scope**: 1060 | 1061 | - Refactor or generate context functions to require a scope argument 1062 | - Ensure all queries filter by organization_id using scope.organization.id 1063 | - Add membership management functions (create, update, delete) 1064 | - Implement safeguards like "last admin" protection 1065 | 1066 | 8. **Authorization (Bodyguard) Setup**: 1067 | 1068 | - Add Bodyguard to your project and define policy modules 1069 | - Implement authorization rules that check membership roles 1070 | - Create separate policy modules for different contexts (accounts, projects, etc.) 1071 | - Ensure policies account for multi-org (checking role in specific org) 1072 | 1073 | 9. **Enforce Authorization in Code**: 1074 | 1075 | - Add Bodyguard.permit checks in controllers and LiveViews 1076 | - Implement proper error handling for unauthorized actions 1077 | - Add role-based query filtering where appropriate 1078 | - Ensure all operations that modify data check authorization first 1079 | 1080 | 10. **LiveView Forms and Components**: 1081 | 1082 | - Use to_form/1 for form handling in LiveViews 1083 | - Implement organization management forms (create, edit, invite users) 1084 | - Create an organization switcher component for the header 1085 | - Add appropriate feedback for actions and errors 1086 | 1087 | 11. **Testing**: 1088 | 1089 | - Write tests for multi-tenant data isolation 1090 | - Test authorization rules for different roles 1091 | - Test membership management and organization switching 1092 | - Create fixtures and helpers for organization testing 1093 | 1094 | 12. **UI/UX Polishing**: 1095 | - Add navigation elements for organization switching 1096 | - Display current organization name in the layout 1097 | - Conditionally display UI elements based on user's role 1098 | - Implement organization onboarding flow and invitation handling 1099 | 1100 | By following these steps, you will have a comprehensive multi-tenant Phoenix application that properly supports users belonging to multiple organizations with different roles in each. Phoenix 1.8's scopes feature works in tandem with explicit authorization checks to provide a secure, maintainable foundation for your multi-tenant system. 1101 | 1102 | ## References 1103 | 1104 | - [Phoenix Scopes Documentation](https://hexdocs.pm/phoenix/1.8.0-rc.0/scopes.html) 1105 | - [Phoenix Authentication Generator](https://hexdocs.pm/phoenix/1.8.0-rc.0/Mix.Tasks.Phx.Gen.Auth.html) 1106 | - [Bodyguard GitHub Repository](https://github.com/schrockwell/bodyguard) 1107 | --------------------------------------------------------------------------------