Route Groups
Route groups let you register the same models under multiple URL prefixes, each with its own middleware stack and authentication behavior. This enables hybrid routing where different parts of your application serve the same data with different access rules.
Route Registration
Routes are registered automatically. Define your route groups in the RhinoConfig you pass to RhinoModule.forRoot(), then call applyRhinoRouting() in main.ts:
import { NestFactory } from '@nestjs/core';
import { applyRhinoRouting } from '@rhino-dev/rhino-nestjs';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
applyRhinoRouting(app, { prefix: 'api' });
await app.listen(3000);
}
bootstrap();
applyRhinoRouting() reads your configuration and registers all CRUD routes, auth routes, invitation routes, and nested operation routes for every group automatically.
Configuration
Define route groups in src/rhino.config.ts. Middleware entries are NestJS middleware classes (Type<NestMiddleware>), not string names:
import { ResolveOrganizationMiddleware } from '@rhino-dev/rhino-nestjs';
// Inside the RhinoConfig object passed to RhinoModule.forRoot()
models: {
posts: { model: 'post' },
comments: { model: 'comment' },
categories: { model: 'category' },
},
routeGroups: {
tenant: {
prefix: ':organization',
middleware: [ResolveOrganizationMiddleware],
models: '*',
},
admin: {
prefix: 'admin',
tenant: false,
models: '*',
},
public: {
prefix: 'public',
skipAuth: true,
models: ['categories'],
},
},
multiTenant: {
enabled: true,
organizationIdentifierColumn: 'slug',
},
Structure
A route group (RouteGroupConfig) accepts:
| Property | Type | Default | Description |
|---|---|---|---|
prefix | string | — | URL prefix for all routes in this group (e.g., ':organization', 'admin', '') |
domain | string | — | Optional host constraint (see Domain Constraints) |
middleware | Type<NestMiddleware>[] | [] | Middleware applied to all routes in this group |
models | '*' | string[] | — | '*' for all registered models, or an array of specific model slugs |
skipAuth | boolean | false | Skip the JWT guard for this group (used by the reserved public group) |
auth | boolean | false | Register a group-tagged auth route set (see Group membership & auth) |
hooks | Type<AuthLifecycleHooks> | AuthLifecycleHooks | — | Per-group lifecycle hooks (see Group membership & auth) |
tenant | boolean | inferred | Whether the group is org-scoped. When omitted, a group is a tenant group iff multi-tenancy is enabled; set tenant: false for org-less groups (e.g. admin, driver) when multi-tenancy is on. tenant: false also lifts the resource-scope resolver's tenant requirement for the group — see Tenant Boundary |
Reserved Group Names
Two group names have special behavior:
-
tenant-- Rhino detects this name and:- Registers invitation CRUD routes under the tenant prefix
- Registers the nested operations endpoint under the tenant prefix
- The middleware (
ResolveOrganizationMiddleware) setsreq.organization, enabling automatic organization scoping
-
public-- Rhino detects this name and:- Implies
skipAuth: true, so the JWT guard is skipped for all routes in this group
- Implies
Any other group name (e.g., 'driver', 'admin', 'default') is treated as a standard authenticated group.
Route Naming
All routes are named with the pattern rhino.{groupKey}.{modelSlug}.{action}:
rhino.tenant.posts.index
rhino.tenant.posts.store
rhino.admin.posts.show
rhino.public.categories.index
Registration Order
Route groups with literal prefixes (e.g., admin, public) are registered before groups with parameterized prefixes (containing :, e.g., :organization). This prevents parameterized routes from capturing requests meant for literal prefixes.
Domain Constraints
A route group can be constrained to a specific host with the optional domain
key, so two groups can share the same prefix while living on different hosts.
routeGroups: {
// Only matches requests to admin.example.com
admin: {
prefix: '',
domain: 'admin.example.com',
models: '*',
},
},
domain value | Behavior |
|---|---|
| omitted | 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' | Parameterized; the captured {organization} feeds org resolution like a path-prefix tenant param |
domain and prefix are independent and combine. A parameterized domain binds
the captured subdomain exactly like the path-prefix :organization, so subdomain
multi-tenancy works with no extra wiring (see Multi-Tenancy → Subdomain Mode).
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
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 |
|---|---|
NULL (absent) | Wildcard — member of every group (the back-compat default) |
'driver' | Membership scoped to the driver group only |
Enforcement is gated by the master flag on auth:
RhinoModule.forRoot({
auth: { enforceGroupMembership: false }, // default OFF
// ...
})
- Off (default): no membership check; the permission source is the existing org-presence heuristic (see Permission Resolution by Group).
- On: after authentication, the user must hold a
user_rolesrow whoseroute_groupmatches the request's group (aNULL/absent row 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. The default/empty-prefix group is itself a first-class membership dimension, so enforcement applies to it uniformly.
Membership is the coarse gate (may you enter the group at all); permissions
remain the fine check (what may you do). The public group skips both.
A NULL 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
Set auth: true on 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 name (Decision 9.A). The matched route
makes the group unambiguous, so the controller knows which group is signing in.
routeGroups: {
driver: {
prefix: 'driver',
auth: true, // adds POST /api/driver/auth/login, …/logout, …/register, …/password/*
tenant: false,
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
authand adomaingets…/auth/loginon that host; with a prefix it gets{prefix}/auth/login. Both carry the group's name. publicis never auth-enabled.
A group with auth: true, an empty prefix, and no domain is
indistinguishable from the legacy /api/auth/* set, so Rhino resolves the
default auth paths to that group (keeping skipAuth semantics) and adopts its
hooks — it does not register a second, colliding route, even when a
host-claiming empty-prefix domain group also exists. If two or more
auth-enabled groups have an empty prefix and no domain, they are genuinely
indistinguishable and Rhino throws a boot-time RouteGroupConflictError. Give
each a distinct prefix or domain.
Lifecycle hooks
A group may declare an optional hooks provider implementing
AuthLifecycleHooks (a class resolved via Nest DI, or a plain object). Each
method runs after its auth action succeeds, receiving an AuthHookContext
({ user, routeGroup, organization?, token?, request }):
import { Injectable } from '@nestjs/common';
import { AuthLifecycleHooks, AuthHookContext, RhinoAuthRejected } from '@rhino-dev/rhino-nestjs';
@Injectable()
export class DriverAuthHooks implements AuthLifecycleHooks {
async afterLogin(ctx: AuthHookContext): Promise<void> {
if (!ctx.user.driver?.active) {
// Revokes the just-issued token and returns 403.
throw new RhinoAuthRejected(403, 'Driver account is suspended.');
}
}
}
routeGroups: {
driver: {
prefix: 'driver',
auth: true,
hooks: DriverAuthHooks, // provided via Nest DI
tenant: false,
models: ['trips'],
},
},
| Event | Fires after | Can reject? |
|---|---|---|
afterLogin | successful login (token issued) | yes — revokes the token, returns the status |
afterRegister | invitation-accept registration | yes — revokes the token, returns the status |
afterLogout | logout | yes (token is already gone; returns the status) |
afterPasswordReset | password reset completed | yes |
afterPasswordRecover | recovery email requested | runs, but the rejection is swallowed (see note) |
A hook rejects by throwing RhinoAuthRejected(status = 403, message) (or any
HttpException). 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. Default status is 403.
afterPasswordRecover 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 afterLogin/afterRegister/afterLogout/afterPasswordReset.
RevokedToken modelOn rejection of afterLogin/afterRegister the controller always drops the
token from the response, and attempts to denylist it via a RevokedToken
Prisma model (token, createdAt). With no such model the revocation is logged
and skipped — provision RevokedToken and use short-TTL JWTs
(auth.jwtExpiresIn) if you rely on revocation.
Invitations carry the group
When a group is an access boundary, invitations record which group the invitee joins:
- Invite creation stores a
route_group(with an org only for tenant groups — non-tenant group invites store aNULLorg). - Accept populates the
user_rolesmembership with thatroute_group(+ org + role), then fires the group'safterRegisterhook. - You cannot invite into the
publicgroup. A forged/unknownrouteGroup(not configured, notpublic) is rejected with 422 regardless of enforcement. - When enforcement is on, a coarse membership gate runs first — the inviter must
themselves be a member of the target group (403 on denial; a
NULLrow is a wildcard) — then the normal permission check.
Examples
Simple Non-Tenant App
routeGroups: {
default: {
prefix: '',
models: '*',
},
},
Routes: GET /api/posts, POST /api/posts, etc. All routes require authentication.
Simple Multi-Tenant App
import { ResolveOrganizationMiddleware } from '@rhino-dev/rhino-nestjs';
routeGroups: {
tenant: {
prefix: ':organization',
middleware: [ResolveOrganizationMiddleware],
models: '*',
},
},
multiTenant: {
enabled: true,
organizationIdentifierColumn: 'slug',
},
Routes: GET /api/:organization/posts, etc. All routes require auth + organization resolution.
Hybrid Platform
import { ResolveOrganizationMiddleware } from '@rhino-dev/rhino-nestjs';
models: {
trips: { model: 'trip' },
trucks: { model: 'truck' },
materials: { model: 'material' },
},
routeGroups: {
// Customer dashboard -- org-scoped
tenant: {
prefix: ':organization',
middleware: [ResolveOrganizationMiddleware],
models: '*',
},
// Driver app -- authenticated, not org-scoped
driver: {
prefix: 'driver',
tenant: false,
models: ['trips', 'trucks'],
},
// Admin panel -- authenticated, global access
admin: {
prefix: 'admin',
tenant: false,
models: '*',
},
// Public API -- no auth
public: {
prefix: 'public',
skipAuth: true,
models: ['materials'],
},
},
Generated routes:
| Group | URL Pattern | Auth | Org Scoped |
|---|---|---|---|
| tenant | /api/:organization/trips | Yes | Yes |
| tenant | /api/:organization/materials | Yes | Yes |
| driver | /api/driver/trips | Yes | No |
| driver | /api/driver/trucks | Yes | No |
| admin | /api/admin/trips | Yes | No |
| admin | /api/admin/materials | Yes | No |
| public | /api/public/materials | No | No |
Organization Scoping Behavior
Organization scoping is implicit, based on the middleware stack:
- Organization present (set by
ResolveOrganizationMiddleware): scoping is applied automatically. - Organization absent (no middleware sets it): scoping is skipped, query returns all records.
This means:
tenantgroup routes get org scoping automatically (their middleware setsreq.organization).- Non-tenant group routes skip org scoping naturally (no middleware sets an org).
- No configuration flag needed -- the behavior is implicit based on the middleware stack.
Tenant Boundary
CRUD scoping is implicit, as above — but the resource-scope resolver used
by your own controllers cannot guess. ResourceScopeService fails closed: an org-scoped model
queried with no ctx.organization throws 403 TENANT_CONTEXT_REQUIRED rather than returning every
tenant's rows. In a group that has no organization to resolve, that throw is wrong — so say so:
routeGroups: {
tenant: { prefix: ':organization', middleware: [ResolveOrganizationMiddleware], models: '*' },
admin: { prefix: 'admin', tenant: false, models: [] }, // spans every organization
},
In a tenant: false group the resolver applies no organization filter and does not throw. Every
other group is unchanged and still fails closed, including the tenant groups in the same app.
The resolver reads the group from ctx.routeGroup. RouteGroupMiddleware already puts it on every
request as req.__routeGroup, so a custom controller just passes it through:
@Controller('admin')
export class AdminDashboardController {
constructor(private readonly scope: ResourceScopeService) {}
@Get('dashboard')
async summary(@Req() req: any) {
const ctx = { user: req.user, routeGroup: req.__routeGroup };
return { tasks_total: await this.scope.count('tasks', ctx) }; // every organization
}
}
Declaring the group also makes its prefix a reserved segment for
createTenantRouteRewrite, so /api/admin/dashboard is never mistaken for an organization slug and
rewritten (or rejected as an unknown tenant).
A context with no routeGroup — a queued job, a script, a controller that forgets to pass it — keeps
failing closed. Code with no request names the group itself ({ routeGroup: 'admin' }) or passes
ctx.organization explicitly. Naming a group that is not declared tenant: false still fails closed.
See Multi-Tenancy — Naming the group where there is no request.
See Multi-Tenancy — Route Groups Without a Tenant Boundary.
Custom Scoping for Non-Tenant Groups
For non-tenant groups (e.g., driver, admin), custom data filtering is implemented with a RhinoScope class attached to the model registration via scopes. The scope's apply(where, ctx) augments the Prisma where clause; ctx includes the current user and resolved route group:
import type { RhinoScope } from '@rhino-dev/rhino-nestjs';
export class TripScope implements RhinoScope {
apply(where: Record<string, any>, ctx: { user?: any; routeGroup?: string | null }) {
if (ctx.routeGroup === 'driver' && ctx.user?.driverId) {
return { ...where, driverId: ctx.user.driverId };
}
return where;
}
}
import { TripScope } from './scopes/TripScope';
models: {
trips: { model: 'trip', scopes: [TripScope] },
},
Permission Resolution
Two permission sources are used, determined by the route group context:
| Route Group | Permission Source | Description |
|---|---|---|
'tenant' | layered, org-scoped | (role ∪ granted) − denied per org (see below) |
| Any other | users.permissions | User-level, checked directly on the user model |
For a tenant group the permissions are resolved through the layered
model: effective = (role ∪ granted) − denied, where role is the shared
OrgRolePermission for (org, role), and granted/denied are the per-user
deltas (grantedPermissions / deniedPermissions) on userRoles. The legacy
userRoles.permissions is still honored as an allow layer.
Deny always wins — a permission in deniedPermissions is denied even under a
role *. The decision is deterministic based on the presence of an organization
in the request context. See Layered Permissions
for the full model, the required Prisma schema, and the
npx rhino permissions-migrate command.