Route Groups
Route groups allow you to register the same models under multiple URL prefixes, each with its own middleware stack and authentication behavior. This enables hybrid platforms where different user types access resources through different contexts.
Configuration
Define route groups in config/initializers/rhino.rb:
Rhino.configure do |config|
config.route_group :group_name, prefix: 'url-prefix', middleware: [SomeMiddleware], models: :all
end
| Keyword | Type | Default | Purpose |
|---|---|---|---|
prefix: | String | — | URL prefix for the group's routes |
domain: | String / nil | nil | Optional host constraint (see Domain Constraints) |
middleware: | Array | [] | Middleware stack applied on top of authentication |
models: | :all / Array | — | :all for all registered models, or an array of model slugs |
tenant: | Boolean | true | false declares the group has no tenant boundary (see Tenant Boundary) |
auth: | Boolean | false | Register a group-tagged auth route set (see Group membership & auth) |
hooks: | Class | nil | A class responding to the lifecycle event methods, run after each auth action |
Reserved Group Names
Two group names have special behavior:
| Name | Behavior |
|---|---|
:tenant | Invitation and nested routes are registered under this group's prefix |
:public | Authentication is skipped for routes in this group |
All other group names (e.g., :driver, :admin, :default) are standard authenticated groups.
Model Selection
models: :all— registers all models fromconfig.modelsmodels: [:posts, :categories]— registers only the specified model slugs
Domain Constraints
A route group can be constrained to a specific host with the optional domain:
keyword. This lets two groups share the same prefix: while living on different
domains.
Rhino.configure do |config|
config.model :posts, 'Post'
# Only matches requests to admin.example.com
config.route_group :admin, prefix: '', domain: 'admin.example.com', models: :all
end
Semantics:
domain: value | Behavior |
|---|---|
omitted / nil / '' | Matches any host (default, fully backward compatible) |
'admin.example.com' | Group's routes match only that exact host; other hosts get a 404 |
'{organization}.example.com' | Matches that host pattern and captures {organization}, feeding org resolution |
domain: and prefix: are independent and combine — a group may have both.
Two groups that share the same prefix and overlapping models and an
intersecting host-set would silently shadow each other. Rhino validates the
config when routes are drawn and raises Rhino::RouteGroupConflictError in that
case. The fix is to give them distinct prefixes, distinguish them with different
domain: values, or make their models: disjoint. A group without a domain:
matches every host, so it intersects with all others.
Subdomain multi-tenancy
A parameterized domain captures a single host label and exposes it exactly
like the path-prefix :organization parameter, so subdomain multitenancy works
out of the box:
Rhino.configure do |config|
config.model :posts, 'Post'
config.route_group :tenant,
prefix: '',
domain: '{organization}.example.com',
middleware: [Rhino::Middleware::ResolveOrganizationFromRoute],
models: :all
config.multi_tenant = { organization_identifier_column: 'slug' }
end
A request to org-one.example.com resolves the org-one organization (by the
configured identifier column) and scopes all data to it. Requests to an unknown
subdomain — or to an organization the authenticated user does not belong to —
return 404. The capture matches a single label only, so example.com (no
subdomain) and a.b.example.com (multi-label) do not match.
When the :tenant group declares a domain, the tenant-scoped invitation and
nested (/nested) routes inherit that same domain constraint.
Internally the constraint is implemented by Rhino::Routing::DomainConstraint,
which compiles the pattern to an anchored, case-insensitive regex ({name}
becomes (?<name>[^.]+)) and injects captured values into the request's path
parameters so ResolveOrganizationFromRoute (and the controller) resolve the
organization from the subdomain.
Group membership & auth
By default a route group only chooses a URL/host context and a permission
source — it is not an access boundary, and auth (/api/auth/*) is
group-blind. Three opt-in, fully backward-compatible features turn the group
into a first-class boundary. With all flags off, behavior is byte-for-byte
what it is today.
Group membership on user_roles
A migration adds a nullable route_group column to user_roles (and makes
organization_id nullable, since non-tenant groups like :admin/:driver have
no org). A membership row is now keyed by (user, route_group, organization, role).
route_group value | Meaning |
|---|---|
nil | Wildcard — member of every group (the back-compat default) |
'driver' | Membership scoped to the driver group only |
Enforcement is gated by the master flag in config/initializers/rhino.rb:
config.auth = { enforce_group_membership: false } # default OFF
- Off (default): no membership check; the permission source is the existing org-presence heuristic (see Permission Resolution).
- On: after authentication, the user must hold a
user_rolesrow whoseroute_groupmatches the request's group (anilrow is a wildcard match) and, for tenant groups, the resolved organization. No match → 403. Permissions then resolve from that matching membership row (per[group, org]), not the heuristic. When both an exact-group row and a wildcard (nil) row exist, the exact row is preferred.
Membership is the coarse gate (may you enter the group at all); permissions
remain the fine check (what may you do). They run in sequence and are never
merged. The :public group skips both (no auth).
A nil route_group is a wildcard, so enabling enforcement never locks out
existing rows. Scope rows to concrete groups only when you want to restrict them.
Group-aware auth
Pass auth: true to a group to register the full auth route set — login,
logout, password/recover, password/reset, register — under that group's
prefix/domain, tagged with the group's route_group (exactly how CRUD routes
are generated). The matched route makes the group unambiguous, so the controller
knows which group is signing in.
# adds POST /api/driver/auth/login, …/logout, …/register, …/password/*
config.route_group :driver, prefix: 'driver', auth: true, models: [:trips]
- The legacy unprefixed
/api/auth/*set always remains and maps to the:default/no-group case, preserving today's behavior for apps that don't opt in. - A group with
auth:and adomain:gets…/auth/loginon that host; with a prefix it gets{prefix}/auth/login. Both carry the group'sroute_group. - The resolved
route_groupflows into membership checks and lifecycle hooks. :publicis never auth-enabled.
A group with auth: true, an empty prefix, and no domain has nothing to
distinguish its auth routes from the legacy /api/auth/* set — so Rhino treats
it as the default/legacy auth. The unprefixed /api/auth/* routes adopt that
group's route_group (and its hooks:); no second, colliding route is drawn.
Groups with a distinguishing prefix or domain keep their own per-group auth routes,
and apps with no auth-enabled group keep today's group-less legacy auth unchanged.
If two or more auth-enabled groups have an empty prefix and no domain,
they are genuinely indistinguishable, and Rhino raises a boot-time
Rhino::RouteGroupConflictError. Give each a distinct prefix: or domain:.
Lifecycle hooks
A group may declare an optional hooks: class responding to
after_login / after_logout / after_register / after_password_recover /
after_password_reset (subclass Rhino::AuthHooks for no-op defaults). The
relevant auth action calls it after it succeeds, passing a context hash
({ user:, route_group:, organization:, token:, request: }):
class DriverAuthHooks < Rhino::AuthHooks
def after_login(ctx)
unless ctx[:user].driver&.active?
# Revokes the just-issued token and returns 403.
raise Rhino::AuthRejected.new('Driver account is suspended.', status: 403)
end
end
end
config.route_group :driver, prefix: 'driver', auth: true, hooks: DriverAuthHooks, models: [:trips]
| Event | Fires after | Can reject? |
|---|---|---|
after_login | successful login (token issued) | yes — revokes the token, returns the status |
after_register | invitation-accept registration | yes — revokes the token, returns the status |
after_logout | logout | yes (token is already gone; returns the status) |
after_password_reset | password reset completed | yes |
after_password_recover | recovery email requested | runs, but the rejection is swallowed (see note) |
A hook rejects by raising Rhino::AuthRejected.new(message, status: 403). For
token-issuing actions (login, register) the controller revokes the
just-issued token and returns the given status; for the others it returns the
status with no side effects. The default status is 403; a hook may set 401/409/etc.
after_password_recover rejections are swallowedThe recover action still runs the hook (so side effects like auditing or
throttling happen), but swallows any rejection — it always returns the same
uniform "recovery email sent" response whether or not the email exists.
Surfacing the hook's status would turn recover into an email-enumeration oracle.
Reject semantics are preserved for after_login/after_register/after_logout/after_password_reset.
Invitations carry the group
When a group is an access boundary, invitations record which group the invitee joins:
- Invite creation stores a
route_group(withorganization_idonly for tenant groups — non-tenant group invites store anilorg). - Accept populates the
user_rolesmembership with thatroute_group(+ org + role), then fires the group'safter_registerhook. - You cannot invite into the
:publicgroup (it has no auth). - When enforcement is on, the inviter must themselves be a member of the target group.
Examples
Simple Non-Tenant App
Rhino.configure do |config|
config.model :posts, 'Post'
config.model :comments, 'Comment'
config.route_group :default, prefix: '', middleware: [], models: :all
end
Routes: GET /api/posts, POST /api/posts, etc.
Simple Multi-Tenant App
Rhino.configure do |config|
config.model :posts, 'Post'
config.model :organizations, 'Organization'
config.route_group :tenant, prefix: ':organization', middleware: [ResolveOrganizationFromRoute], models: :all
config.multi_tenant = { organization_identifier_column: 'slug' }
end
Routes: GET /api/:organization/posts, POST /api/:organization/posts, etc.
Hybrid Platform (Logistics Example)
A logistics platform with four user types accessing the same resources differently:
Rhino.configure do |config|
config.model :trips, 'Trip'
config.model :construction_sites, 'ConstructionSite'
config.model :trucks, 'Truck'
config.model :materials, 'Material'
config.model :organizations, 'Organization'
# Customer dashboard — org-scoped, full CRUD
config.route_group :tenant, prefix: ':organization', middleware: [ResolveOrganizationFromRoute], models: :all
# Driver app — authenticated, not org-scoped
config.route_group :driver, prefix: 'driver', middleware: [], models: [:trips, :construction_sites, :trucks]
# Admin panel — authenticated, global access to everything
config.route_group :admin, prefix: 'admin', middleware: [], models: :all
# Public API — no authentication, read-only reference data
config.route_group :public, prefix: 'public', middleware: [], models: [:materials]
config.multi_tenant = { organization_identifier_column: 'slug' }
end
This generates:
| Group | Example Route | Auth | Org Scoped |
|---|---|---|---|
| tenant | GET /api/acme-corp/trips | authenticated | Yes |
| tenant | GET /api/acme-corp/materials | authenticated | Yes |
| driver | GET /api/driver/trips | authenticated | No |
| driver | GET /api/driver/trucks | authenticated | No |
| admin | GET /api/admin/trips | authenticated | No |
| admin | GET /api/admin/materials | authenticated | No |
| public | GET /api/public/materials | None | No |
Route Naming
All routes are named with the pattern rhino_{group}_{model}_{action}:
rhino_tenant_trips_index
rhino_tenant_trips_store
rhino_tenant_trips_show
rhino_driver_trips_index
rhino_driver_trucks_show
rhino_admin_trips_index
rhino_public_materials_index
How Organization Scoping Works
Organization scoping is implicit, not configured per group. The ResourcesController's apply_organization_scope checks if the request has an organization in request.env["rhino.organization"]:
- Tenant group → middleware sets
rhino.organizationon the request → scoping applied - Other groups → no middleware sets organization → scoping skipped, queries return all records
This means you don't need any extra configuration for non-tenant groups to bypass org scoping — it happens naturally.
Tenant Boundary
CRUD scoping is implicit, as above — but the resource-scope resolver used
by your own controllers cannot guess. Rhino.query fails closed: an organization-scopable model
queried with no organization raises Rhino::MissingTenantContext rather than returning every
tenant's rows. In a group that has no organization to resolve, that raise is wrong — so say so:
config.route_group :admin,
prefix: "admin",
tenant: false, # queries here legitimately span every organization
models: []
In a tenant: false group, Rhino.query and Rhino.scoped_query apply no organization filter and
do not raise. Every other group is unchanged and still fails closed, including the tenant groups in
the same app.
The group comes from the request's route_group, the same value memberships and policies use.
Rhino's own controllers publish it; a custom controller declares it:
class AdminDashboardController < ApplicationController
include Rhino::RouteGroupContext
rhino_route_group :admin
def summary
render json: { tasks: Rhino.query(Task).count } # every organization
end
end
The concern falls back to the route's own default, so tagging the route works too — useful when one controller is mounted under more than one group:
get "/api/admin/dashboard", to: "admin_dashboard#summary",
defaults: { route_group: "admin" }
Declare those routes before any :organization-prefixed route, or /api/admin/dashboard is
matched as the tenant route with :organization = "admin".
Outside a request — an Active Job, a rake task, the console — no group resolves, so code there names
the one it is acting as with Rhino.in_route_group(:admin) (optionally chained with for_user), or
passes a tenant explicitly with Rhino.for_user(user).in_organization(org). Naming a group that is
not declared tenant: false still fails closed. See
Multi-Tenancy — Naming the group where no request resolves one.
See Multi-Tenancy — Route Groups Without a Tenant Boundary.
Custom Scoping for Non-Tenant Groups
For groups like driver that need custom data filtering (e.g., a driver only sees their own trips), use standard Rails scoping mechanisms:
module DriverScopable
extend ActiveSupport::Concern
included do
default_scope do
if RequestStore.store[:route_group] == 'driver'
where(driver_id: Current.user&.driver_id)
else
all
end
end
end
end
class Trip < ApplicationRecord
include Rhino::HasRhino
include Rhino::HasValidation
include DriverScopable
# ...
end
Now when a driver accesses GET /api/driver/trips, they only see their own trips. When an admin accesses GET /api/admin/trips, they see all trips.
The current route group is available via params[:route_group] in the controller. You can also access it via route defaults for use in model scopes.
Permission Resolution
Rhino uses two permission sources based on the route group context:
| Route Group | Permission Source | When Used |
|---|---|---|
:tenant | layered, org-scoped ((role ∪ granted) − denied) | Organization middleware sets org on request |
| Any other | users.permissions | No organization context |
Setup
Add a permissions JSON column to your users table:
class AddPermissionsToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :permissions, :json
end
end
Add the column to your User model's attributes:
class User < ApplicationRecord
include Rhino::HasPermissions
# ...
end
Assigning Permissions
# Driver: can view and manage their trips and trucks
driver.update!(permissions: ['trips.index', 'trips.show', 'trucks.*'])
# Platform admin: full access to everything
admin.update!(permissions: ['*'])
How It Works
When has_permission? is called:
- Organization present (tenant route group) → resolves the layered
permissions for that organization:
effective = (role ∪ granted) − denied, whereroleis the sharedorg_role_permissionsfor(org, role), andgranted/deniedare the per-user deltas onuser_roles. The legacyuser_roles.permissionsand the globalroles.permissionsare still honored. - No organization (any other route group) → checks
users.permissionsdirectly (plus an optionalusers.denied_permissions).
Deny always wins — a permission in denied_permissions is denied even under
a role *. The decision is deterministic, based on the presence of an
organization in the request (set by middleware in tenant route groups).
See Layered Permissions for the full model and the migration task.
Request Flow Walkthrough
Customer Request: GET /api/acme-corp/trips
- Route matches
rhino_tenant_trips_index - Authentication middleware authenticates the user
ResolveOrganizationFromRoutemiddleware resolves "acme-corp" to an Organization model- Organization is set on the request:
request.env["rhino.organization"] = org - ResourcesController
apply_organization_scopefinds the organization → scopes query to org - Response contains only Acme Corp's trips
Driver Request: GET /api/driver/trips
- Route matches
rhino_driver_trips_index - Authentication middleware authenticates the user
- No organization middleware → no org on request
- ResourcesController
apply_organization_scopefinds no organization → skips org scope DriverScopabledefault scope detectsroute_group = 'driver'→ filters bydriver_id- Response contains only the driver's trips
Admin Request: GET /api/admin/trips
- Route matches
rhino_admin_trips_index - Authentication middleware authenticates the user
- No organization middleware → no org on request
- ResourcesController
apply_organization_scopefinds no organization → skips org scope DriverScopabledefault scope detectsroute_group = 'admin'→ does nothing- Response contains all trips across all organizations
Public Request: GET /api/public/materials
- Route matches
rhino_public_materials_index - No authentication (public group)
- No organization middleware
- ResourcesController serves the request without auth or org scoping
- Response contains all materials
Migration from Previous Config
If you're upgrading from a previous Rhino version, update your config/initializers/rhino.rb:
Before:
Rhino.configure do |config|
config.model :posts, 'Post'
config.public_model :materials
config.multi_tenant = {
enabled: true,
use_subdomain: false,
organization_identifier_column: 'slug'
}
end
After:
Rhino.configure do |config|
config.model :posts, 'Post'
config.model :materials, 'Material'
config.route_group :tenant, prefix: ':organization', middleware: [ResolveOrganizationFromRoute], models: :all
config.route_group :public, prefix: 'public', middleware: [], models: [:materials]
config.multi_tenant = { organization_identifier_column: 'slug' }
end
Key changes:
- Remove
public_modelcalls → use a:publicroute group instead - Remove
enabled,use_subdomain, andmiddlewarefrommulti_tenant→ these are now expressed viaroute_groups - Keep
organization_identifier_columninmulti_tenant(still used by middleware)