├── .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 |
<%= user.email %>
508 |509 | Role: <%= String.capitalize(user.role) %> 510 |
511 |<%= membership.user.email %>
921 |922 | Role: <%= String.capitalize(membership.role) %> 923 |
924 |