plutonium-tenancy

$npx mdskill add radioactive-labs/plutonium-core/plutonium-tenancy

Three closely-coupled concerns:

SKILL.md

.github/skills/plutonium-tenancyView on GitHub ↗
---
name: plutonium-tenancy
description: Use BEFORE any multi-tenant work — scoping a model to a tenant, writing relation_scope, configuring portal entity strategies, setting up parent/child nested resources, or wiring user invitations. The single source for entity scoping, nested resources, and invites.
---

# Plutonium Tenancy — Entity Scoping, Nested Resources, Invites

Three closely-coupled concerns:

1. **Entity scoping** — every record belongs to a tenant; queries are filtered automatically.
2. **Nested resources** — parent/child URLs; parent scoping takes precedence over entity scoping.
3. **Invites** — onboarding users into a tenant's membership.

Cross-references back to [[plutonium-resource]] (models, definitions) and [[plutonium-behavior]] (policies, controllers).

## 🚨 Critical (read first)

- **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins to the entity triggers `verify_default_relation_scope_applied!`. Make sure the chain ends up calling `default_relation_scope(relation)` — explicitly, or via `super(relation)` (the framework base calls it).
- **Always declare an association path from model to entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, Plutonium raises. Fix the **model**, not the policy.
- **Parent scoping beats entity scoping.** When a parent is present (nested resource), `default_relation_scope` scopes via the parent, NOT via `entity_scope`. Don't double-scope.
- **One level of nesting only.** Grandparent → parent → child nested routes are NOT supported. Use top-level routes for deeper relationships.
- **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
- **Invite email must match the accepting user's email.** Security feature. Don't disable `enforce_email?` lightly.
- **Use generators.** `pu:saas:setup`, `pu:pkg:portal --scope=Entity`, `pu:res:scaffold`, `pu:invites:install`, `pu:invites:invitable`. Hand-wiring is how leaks happen.

---

# Part 1 — Entity Scoping

Built on three cooperating pieces:

| Piece | Role |
|---|---|
| **Portal** | Declares the entity class and how to resolve it (`scope_to_entity Organization, strategy: :path`). |
| **Policy** | `default_relation_scope(relation)` calls `relation.associated_with(entity_scope)` on every collection query. Enforced via `verify_default_relation_scope_applied!`. |
| **Model** | `associated_with(entity)` resolves via custom scope, direct association, or `has_one :through`. |

## `associated_with` resolution order

`Model.associated_with(entity)` tries, in order:

1. **Custom scope** `associated_with_<entity_name>` — highest priority, full SQL control.
2. **Direct `belongs_to` to entity class** — `WHERE <entity>_id = ?`, most efficient.
3. **`has_one` / `has_one :through` to entity class** — JOIN + WHERE, auto-detected via `reflect_on_all_associations`.
4. **Reverse `has_many` from entity** — JOIN required, logs a warning (less efficient).

If none apply: `Could not resolve the association between 'Model' and 'Entity'`. Fix on the **model** — either declare an association path (`belongs_to`, `has_one :through`) OR define a custom `associated_with_<entity>` scope. Never work around this by overriding `relation_scope` in the policy.

## Three model shapes

Pick the lightest that fits.

### Shape 1: Direct child (`belongs_to` the entity)

```ruby
class Organization < ResourceRecord
  has_many :projects
end

class Project < ResourceRecord
  belongs_to :organization
end

Project.associated_with(org)
# => Project.where(organization: org)
```

Auto-detected. No extra work.

### Shape 2: Join table (membership)

```ruby
class User < ResourceRecord
  has_many :memberships
  has_many :organizations, through: :memberships
end

class Membership < ResourceRecord
  belongs_to :user
  belongs_to :organization     # auto-detected
end

Membership.associated_with(org)
# => Membership.where(organization: org)
```

If `Membership` is itself a parent and the scoped target is two hops away, add `has_one :through`:

```ruby
class ProjectMember < ResourceRecord
  belongs_to :project
  belongs_to :user
  has_one :organization, through: :project   # enables auto-scoping
end
```

### Shape 3: Grandchild (multi-hop via `has_one :through`)

```ruby
class Project < ResourceRecord
  belongs_to :organization
  has_many :tasks
end

class Task < ResourceRecord
  belongs_to :project
  has_one :organization, through: :project   # critical
end

class Comment < ResourceRecord
  belongs_to :task
  has_one :project, through: :task
  has_one :organization, through: :project   # multi-hop chain
end
```

`Task.associated_with(org)` and `Comment.associated_with(org)` both auto-resolve.

### When to fall back to a custom scope

```ruby
class Comment < ResourceRecord
  scope :associated_with_organization, ->(org) do
    joins(task: :project).where(projects: {organization_id: org.id})
  end
end
```

Use when:
- The path is polymorphic.
- Conditional logic is needed.
- You want explicit SQL for performance.

Picked up BEFORE association detection.

## `relation_scope` — safe overrides

`default_relation_scope(relation)` does two things:

1. If a **parent** is present (nested resource), scopes via the parent association.
2. Otherwise, applies `relation.associated_with(entity_scope)`.

### Correct

```ruby
# ✅ Best: don't override — the inherited scope already does it.

# ✅ Extra filters on top
relation_scope do |relation|
  default_relation_scope(relation).where(archived: false)
end

# ✅ Role-based
relation_scope do |relation|
  relation = default_relation_scope(relation)
  user.admin? ? relation : relation.where(author: user)
end
```

### Wrong

```ruby
# ❌ Manually filtering by entity — bypasses default_relation_scope
relation_scope { |r| r.where(organization: current_scoped_entity) }

# ❌ Manual joins — same problem
relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }

# ❌ Missing default_relation_scope entirely — raises at runtime
relation_scope { |r| r.where(published: true) }
```

**`default_relation_scope(relation)` must end up being called somewhere in the chain** — runtime verification just checks it was hit, not that you wrote it in this class. Both work:

- `default_relation_scope(relation).where(...)` — explicit, always safe
- `super(relation).where(...)` — `Plutonium::Resource::Policy`'s `relation_scope` block calls `default_relation_scope`, so chaining through `super` picks it up

Pick the one that reads better for the situation.

### Intentionally skipping

Rare. Before reaching for this, consider a separate, unscoped portal.

```ruby
relation_scope do |relation|
  skip_default_relation_scope!
  relation
end
```

## Portal entity strategies

### Path strategy (most common)

```ruby
module AdminPortal
  class Engine < Rails::Engine
    include Plutonium::Portal::Engine

    config.after_initialize do
      scope_to_entity Organization, strategy: :path
    end
  end
end
```

Routes become `/<mount>/:organization_scoped/posts` (resolving to `/<mount>/42/posts` at request time — the entity id is the first path segment after the mount). Portal extracts `params[:organization_scoped]` and loads the entity automatically. The `_scoped` suffix on the param name avoids colliding with `params[:organization]` from a `belongs_to :organization` on child models.

### Custom strategy (subdomain, session, etc.)

```ruby
scope_to_entity Organization, strategy: :current_organization

module AdminPortal::Concerns::Controller
  extend ActiveSupport::Concern
  include Plutonium::Portal::Controller

  private

  def current_organization
    @current_organization ||= Organization.find_by!(subdomain: request.subdomain)
  end
end
```

The strategy symbol must match a method name on the controller.

### Accessing the scoped entity

```ruby
# Controller
current_scoped_entity
scoped_to_entity?

# Policy
entity_scope
```

## Gotchas

- **Multiple associations to the same entity class.** E.g. `Match belongs_to :home_team, :away_team` both pointing at `Team`. Plutonium raises — override `scoped_entity_association` on the controller to pick one (`def scoped_entity_association = :home_team`).
- **`param_key` differs from association name.** Fine — Plutonium matches by **class**, not param key. `scope_to_entity Competition::Team, param_key: :team` works with `belongs_to :competition_team`.
- **Default `param_key` includes `_scoped` suffix.** `scope_to_entity Organization` reads `params[:organization_scoped]` (not `params[:organization]`) so it doesn't collide with `params[:organization]` from a `belongs_to :organization` on child models. The URL itself is unchanged — the entity id is just the first path segment after the mount (`/<mount>/42/posts`). Pass `param_key:` only if you want a different param name in your controllers.
- **Forgetting compound uniqueness.** `validates :code, uniqueness: true` leaks across tenants. Use `uniqueness: {scope: :organization_id}`.
- **"Temporary" `where` bypass for debugging.** Use `skip_default_relation_scope!` explicitly. Never leave a `where` bypass in code.

---

# Part 2 — Nested Resources

Plutonium auto-generates nested routes from `has_many` / `has_one` associations on a registered parent. **One level only** — no grandparent → parent → child chains.

## Setup

```bash
rails g pu:res:scaffold Company name:string --dest=main_app
rails g pu:res:scaffold Property company:belongs_to name:string --dest=main_app
rails g pu:res:conn Company Property --dest=admin_portal
```

Then register both in the portal routes:

```ruby
register_resource ::Company
register_resource ::Property          # has belongs_to :company
register_resource ::CompanyProfile    # has_one :company_profile on Company
```

## Generated routes

Plutonium prefixes nested routes with `nested_` to avoid conflicts with the top-level routes:

| Route | Purpose |
|---|---|
| `/companies/:company_id/nested_properties` | has_many index |
| `/companies/:company_id/nested_properties/new` | new |
| `/companies/:company_id/nested_properties/:id` | show |
| `/companies/:company_id/nested_company_profile` | has_one show (no `:id`) |
| `/companies/:company_id/nested_company_profile/new` | has_one new |

For `has_one`: index redirects to show (or new if no record exists); only one record per parent.

## Automatic behavior in nested routes

When the controller is hit through a nested route:

1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
2. **Scopes queries** via parent association (e.g. `parent.properties` for `has_many`, `where(foreign_key => parent.id)` for `has_one`).
3. **Assigns parent** on create (injected into `resource_params`).
4. **Hides parent field** in forms (already determined by URL).

You don't need to add hidden parent fields in forms or filter queries manually.

## Controller methods

```ruby
current_parent              # Parent record
current_nested_association  # :properties
parent_route_param          # :company_id
parent_input_param          # :company
```

## Parent vs entity scoping

When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, not `entity_scope`. The parent was already authorized and entity-scoped during its own authorization — double-scoping isn't needed.

```ruby
# In the child policy — just call default_relation_scope, it handles both cases
relation_scope do |relation|
  default_relation_scope(relation)      # uses parent when present, entity_scope otherwise
end
```

## URL generation

```ruby
# Collection
resource_url_for(Property, parent: company)
# => /companies/123/nested_properties

# Record
resource_url_for(property, parent: company)
# => /companies/123/nested_properties/456

# Form
resource_url_for(Property, action: :new, parent: company)
resource_url_for(property, action: :edit, parent: company)

# has_one
resource_url_for(CompanyProfile, action: :new, parent: company)
# => /companies/123/nested_company_profile/new

# Interactions
resource_url_for(property, parent: company, interaction: :archive)
resource_url_for(Property, parent: company, interaction: :import)
resource_url_for(Property, parent: company, interaction: :bulk_delete, ids: [1, 2])

# Cross-package
resource_url_for(property, parent: company, package: CustomerPortal)
```

## Authorization context

The child policy receives the parent:

```ruby
class PropertyPolicy < ResourcePolicy
  # parent              => the Company instance
  # parent_association  => :properties

  def create?
    parent.present? && user.member_of?(parent)
  end
end
```

## Presentation hooks

```ruby
class PropertiesController < ResourceController
  private

  def present_parent?  = true          # show parent in displays (default: false)
  def submit_parent?   = false         # allow changing in forms (defaults to present_parent?)
end
```

Conditional pattern — show parent only when accessed standalone:

```ruby
def present_parent?
  current_parent.nil?
end
```

## Custom parent resolution

```ruby
def current_parent
  @current_parent ||= Company.friendly.find(params[:company_id])
end
```

## Custom nested routes

```ruby
register_resource ::Property do
  member do
    get :analytics, as: :analytics    # `as:` is REQUIRED for resource_url_for to work
    post :archive,  as: :archive
  end
end
```

Generates `/companies/:company_id/nested_properties/:id/analytics` etc.

## Breadcrumbs

Auto-include parent: `Companies > Acme Corp > Properties > Property #123`.

---

# Part 3 — Invites

A complete user-invitation system: token-based emails, secure acceptance, Rodauth integration, entity membership creation, and "invitable" hooks for app-specific behavior.

## Prerequisites

User model + entity model + membership model. The fastest path:

```bash
rails g pu:saas:setup --user Customer --entity Organization
```

This creates all three plus the join table.

## Install

```bash
rails generate pu:invites:install
```

### Options

| Option | Default | Description |
|---|---|---|
| `--entity-model=NAME` | `Entity` | Entity model name |
| `--user-model=NAME` | `User` | User model name |
| `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name (omit for single-flow apps) |
| `--membership-model=NAME` | `EntityUser` | Membership join model (must already exist; roles are read from its `enum :role`) |
| `--rodauth=NAME` | `user` | Rodauth configuration for signup |
| `--enforce-domain` | `false` | Require invited email domain to match entity |
| `--dest=PACKAGE` | `main_app` | Package where the entity model lives (controls where `invite_user_interaction.rb` is generated) |

::: 🚨 No `--roles` flag here
Role list is derived from the membership model's `enum :role`. Set roles via `pu:saas:membership --roles=...` (or edit the enum directly). **Index 0 is the most privileged** — typically `owner`, which the invite UI excludes from selectable choices; new invitees default to the second role (`roles[1]`).
:::

::: 🚨 ActiveRecord encryption keys required
The invite model uses `encrypts :token, deterministic: true`. Without configured AR encryption keys, creating or accepting an invite raises `ActiveRecord::Encryption::Errors::Configuration`. The generator detects this and warns at install time — generate keys with `bin/rails db:encryption:init`, then paste the printed `active_record_encryption:` block into `config/credentials.yml.enc` (or set the equivalent `ACTIVE_RECORD_ENCRYPTION_*` ENV vars in production).
:::

### What gets created

```
packages/invites/
├── app/controllers/invites/
│   ├── user_invitations_controller.rb
│   └── welcome_controller.rb
├── app/definitions/invites/user_invite_definition.rb
├── app/interactions/invites/
│   ├── cancel_invite_interaction.rb
│   └── resend_invite_interaction.rb
├── app/mailers/invites/user_invite_mailer.rb
├── app/models/invites/user_invite.rb
├── app/policies/invites/user_invite_policy.rb
└── app/views/invites/...

app/interactions/{entity,user}/invite_user_interaction.rb
db/migrate/TIMESTAMP_create_user_invites.rb
```

Routes added:

```ruby
get  "welcome",                       to: "invites/welcome#index"
get  "invitations/:token",            to: "invites/user_invitations#show"
post "invitations/:token/accept",     to: "invites/user_invitations#accept"
get  "invitations/:token/signup",     to: "invites/user_invitations#signup"
post "invitations/:token/signup",     to: "invites/user_invitations#signup"
```

## Multiple invite flows in one app

Run `pu:invites:install` once per flow. Default class name derives as `<EntityModel><UserModel>Invite` — no literal `UserInvite` default. Single-flow apps don't need `--invite-model`.

```bash
rails g pu:invites:install \
  --entity-model=FunderOrganization --user-model=SpenderAccount \
  --invite-model=FunderInvite

rails g pu:invites:install \
  --entity-model=Project --user-model=Member \
  --invite-model=ProjectInvite
```

Each invocation creates an independent model (`Invites::FunderInvite`), controller (`Invites::FunderInvitationsController`), route (`/funder_invitations/:token`), and helper (`funder_invitation_path`). The shared `Invites::WelcomeController` accumulates each class into `invite_classes`; `pending_invite` checks all flows in priority order (first-match wins).

Model-level overrides for non-default association names:

```ruby
def user_attribute         = :spender_account     # belongs_to :spender_account
def invite_entity_attribute = :funder_organization # belongs_to :funder_organization
```

Controller-level (auto-generated, but shown for clarity):

```ruby
# welcome_controller.rb
def invite_classes
  [::Invites::FunderInvite, ::Invites::ProjectInvite]
end

# funder_invitations_controller.rb
def invitation_path_for(token)
  funder_invitation_path(token: token)
end
```

## Invitables — app models notified on accept

An "invitable" is an app model that triggers invitations and gets notified when one is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.

```bash
rails generate pu:invites:invitable Tenant
rails generate pu:invites:invitable TeamMember --role=member
rails generate pu:invites:invitable Tenant --dest=my_package
```

Then implement the callback:

```ruby
class Tenant < ApplicationRecord
  include Plutonium::Invites::Concerns::Invitable

  belongs_to :entity
  belongs_to :user, optional: true

  def on_invite_accepted(user)
    update!(user: user, status: :active)
  end
end
```

Without `on_invite_accepted`, the invitable never learns about the new user.

## The flow

### 1. Admin sends the invite

```ruby
entity.invite_user(email: "user@example.com", role: :member)
tenant.invite_user(email: "user@example.com")          # from invitable context
```

### 2. Email goes out

Token-based URL: `https://app.example.com/invitations/abc123...`

### 3. User accepts

**Existing user:** clicks link → logs in (or already logged in) → email validated → membership created → invitable notified via `on_invite_accepted`.

**New user:** clicks link → "Create Account" → signs up with the invited email → membership created → invitable notified.

### 4. Pending invite check

After login, users land on `/welcome` where pending invites are shown:

```ruby
include Plutonium::Invites::PendingInviteCheck
```

Rodauth wiring (required for redirect):

```ruby
# app/rodauth/user_rodauth_plugin.rb
configure do
  login_return_to_requested_location? true
  login_redirect "/welcome"

  after_login do
    session[:after_welcome_redirect] = session.delete(:login_redirect)
  end
end
```

## The UserInvite model

Generated as `Invites::<InviteModelName>`:

```ruby
class Invites::UserInvite < Invites::ResourceRecord
  include Plutonium::Invites::Concerns::InviteToken

  belongs_to :entity
  belongs_to :invited_by, polymorphic: true
  belongs_to :user, optional: true
  belongs_to :invitable, polymorphic: true, optional: true

  enum :state, pending: 0, accepted: 1, expired: 2, cancelled: 3
  enum :role, member: 0, admin: 1
end
```

Key methods:

```ruby
invite = Invites::UserInvite.find_for_acceptance(token)
invite.accept_for_user!(current_user)
invite.resend!
invite.cancel!
```

## Customization

### Custom email templates

Override views in your package:

```erb
<%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
<h1>Welcome to <%= @invite.entity.name %>!</h1>
<p><%= @invite.invited_by.email %> has invited you.</p>
<p><%= link_to "Accept", @invitation_url %></p>
```

### Custom validation

Extend the model:

```ruby
class Invites::UserInvite < Invites::ResourceRecord
  validate :email_not_already_member

  private

  def email_not_already_member
    existing = membership_model.joins(:user)
      .where(entity: entity, users: {email: email}).exists?
    errors.add(:email, "is already a member") if existing
  end
end
```

### Domain enforcement

```bash
rails g pu:invites:install --enforce-domain
```

### Custom roles

Set roles when generating the membership model (ordering: index 0 = most privileged):

```bash
rails g pu:saas:membership --user Customer --entity Organization --roles=admin,editor,viewer
# → enum :role, { owner: 0, admin: 1, editor: 2, viewer: 3 }   (owner auto-prepended)
```

Or edit `enum :role` on the existing membership model directly. Then run `pu:invites:install`.

## Portal connection

```ruby
module CustomerPortal
  class Engine < Rails::Engine
    include Plutonium::Portal::Engine
    register_package Invites::Engine
  end
end
```

Invites are entity-scoped automatically: `Invites::UserInvite belongs_to :entity` → `associated_with` resolves directly → admins see only invites for their org.

## Common issues

- **"Invite not found"** — token expired (default 1 week), invite cancelled, or no longer `pending`.
- **Email mismatch** — `enforce_email?` is on by default. The accepting user's email must match the invited email. Override `def enforce_email? = false` only if you fully understand the security trade-off.
- **Rodauth redirect after login** — make sure `login_redirect "/welcome"` is set in the rodauth plugin.

---

## Related skills

- [[plutonium-resource]] — model declarations (`belongs_to`, `has_one :through`, custom scopes), `permitted_associations` for show-page tabs.
- [[plutonium-behavior]] — `relation_scope` syntax, policy authorization context, controller presentation hooks.
- [[plutonium-app]] — portal setup, `scope_to_entity`, mounting engines.
- [[plutonium-auth]] — Rodauth signup flow for invite acceptance.

More from radioactive-labs/plutonium-core

SkillDescription
plutoniumUse BEFORE starting any Plutonium work — new app, new feature, or first edit in an unfamiliar area. Routes you to the right skill and bootstraps greenfield work.
plutonium-appUse BEFORE installing Plutonium, creating a portal or feature package, mounting an engine, or registering resources/routes. Covers initial setup, the package system, portal engines, route registration (including singular and custom routes), and resource-to-portal wiring.
plutonium-authUse BEFORE installing Rodauth, configuring account types, building login/password flows, or wiring a profile / account-settings page. Covers the full auth surface — Rodauth installation, accounts, admin accounts, SaaS setup, profile resource, security section.
plutonium-behaviorUse BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things".
plutonium-resourceUse BEFORE creating, scaffolding, or editing any Plutonium resource — model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, index views, page customization. The single source for "what is a resource and how do I configure one".
plutonium-testingUse BEFORE writing tests for a Plutonium resource, running pu:test:scaffold, or including Plutonium::Testing::* concerns. Covers the full testing toolkit — CRUD, policy, definition, interaction, model, nested, portal access, and auth helpers.
plutonium-uiUse BEFORE building or customizing any Plutonium UI — page classes, forms, displays, tables, custom Phlex components, layouts, Stimulus controllers, Tailwind config, design tokens, themes, or component classes. Covers the full view + asset toolchain.