Skip to content
Back to skills

Plutonium Tenancy

ASecurity

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.

  • 76 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added May 26, 2026
developmentgorubybashsqlrailsdebuggingsecurityperformance

Works with

  • cli

Security analysis

A100/100

Scanned May 27, 2026

npx -y skills add radioactive-labs/plutonium-core --skill plutonium-tenancy --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Plutonium Tenancy?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Plutonium Tenancy
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/radioactive-labs-plutonium-tenancy/badge)](https://www.skillsdirectory.com/skills/radioactive-labs-plutonium-tenancy)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.