Skip to main content

Multi-Tenancy

Rhino provides built-in multi-tenancy support that isolates data by organization. When an organization is present in the request context (set by middleware), all queries are automatically scoped to that organization, and new records are tagged with the correct organization_id.

Multi-tenancy is configured via Route Groups. Use a tenant route group with organization-resolving middleware to enable org-scoped routing.

Configuration​

Enable multi-tenancy by adding a tenant route group and a multiTenant block in src/rhino.config.ts:

src/rhino.config.ts
import { ResolveOrganizationMiddleware } from '@rhino-dev/rhino-nestjs';

// Inside the RhinoConfig object passed to RhinoModule.forRoot()
routeGroups: {
tenant: {
prefix: ':organization',
middleware: [ResolveOrganizationMiddleware],
models: '*',
},
},
multiTenant: {
enabled: true,
organizationIdentifierColumn: 'slug',
organizationModel: 'organization',
userOrganizationModel: 'userRole',
},

Config Options​

OptionTypeDefaultDescription
enabledbooleanfalseMaster switch for multi-tenancy.
organizationIdentifierColumnstring'id'The column used to look up the organization. Common values: 'id', 'slug', 'uuid'.
organizationModelstring'organization'The Prisma model name for organizations.
userOrganizationModelstring'userRole'The Prisma model name for the user-organization membership join.

Route Groups Without a Tenant Boundary​

Every route group is a tenant group by default, which is what a tenant app wants: the resource-scope resolver fails closed, so ResourceScopeService on an org-scoped model with no ctx.organization throws 403 TENANT_CONTEXT_REQUIRED rather than returning every tenant's rows.

Some groups genuinely have no tenant to resolve — a back office or admin group whose operators are meant to see every organization's records, with access decided by roles and scopes instead of by organization. Declare the group non-tenant:

src/rhino.config.ts
routeGroups: {
tenant: {
prefix: ':organization',
middleware: [ResolveOrganizationMiddleware],
models: '*',
},
admin: {
prefix: 'admin',
tenant: false, // no tenant boundary: queries here span every organization
models: [],
},
},

Inside that group the resolver applies no organization filter and no longer throws. The tenant group in the same app is untouched and keeps failing closed — that is the point of putting the switch on the group rather than on the app.

The group reaches the resolver through ctx.routeGroup. RouteGroupMiddleware sets req.__routeGroup on every request, so a custom controller passes it straight through:

const ctx = { user: req.user, routeGroup: req.__routeGroup };
const total = await this.scope.count('tasks', ctx);

Declaring the group also makes its prefix a reserved segment for createTenantRouteRewrite, so /api/admin/* is never mistaken for an organization slug.

What declaring a group non-tenant does not change​

It removes only the organization filter, and only for contexts carrying that group:

Still applies in a tenant: false groupWhy
The model's scopesYour user-aware scopes are where row-level access lives when there is no organization
namedScopesscopedWhere(slug, ctx, { namedScope }) behaves identically
PoliciesThe resolver scopes rows; the policy guard still decides access
Explicit ctx.organizationAn explicitly passed organization is always honored — the caller asked for that tenant
CRUD through GlobalControllerTenant groups resolve and scope the organization exactly as before

The predicate is stricter than the membership one (isTenantGroup): only an explicit tenant: false opts out, so an unknown group, a context with no routeGroup, and the conventional public group all keep failing closed.

Naming the group where there is no request​

A queued job or a script has no request, so nothing sets req.__routeGroup. Because the context is always explicit in NestJS, such code simply names the group itself — no separate API is needed:

// A back-office job: the 'admin' group is declared tenant: false, so this
// legitimately spans every organization.
await scope.count('tasks', { user: operator, routeGroup: 'admin' });

The config remains the single source of truth: the named group's own tenant: false is what lifts the boundary. { routeGroup: 'tenant' } and an unconfigured group both still throw, as does a context with no routeGroup at all. For a job that belongs to one tenant, pass organization instead — an explicit organization always scopes, in any group.

Only for groups with no tenant boundary

tenant: false removes the guard that turns a forgotten tenant context into a loud 403 — for that group, a missing organization becomes a silent cross-tenant read instead. Declare it only on groups where every operator is meant to see every organization's rows, and keep those groups' model lists narrow (models: [] when the group only serves custom controllers).

Routing Strategies​

URL Prefix Mode​

Use a tenant route group with a parameterized prefix:

src/rhino.config.ts
import { ResolveOrganizationMiddleware } from '@rhino-dev/rhino-nestjs';

routeGroups: {
tenant: {
prefix: ':organization',
middleware: [ResolveOrganizationMiddleware],
models: '*',
},
},

Routes:

GET    /api/:organization/posts
POST /api/:organization/posts
GET /api/:organization/posts/:id
PUT /api/:organization/posts/:id
DELETE /api/:organization/posts/:id

ResolveOrganizationMiddleware extracts the :organization parameter and looks up the Organization model by the configured identifier column.

Subdomain Mode​

For host-based tenancy, give a group a parameterized domain and resolve the organization at the Express layer with createDomainRouteResolver in main.ts:

src/rhino.config.ts
routeGroups: {
tenant: {
prefix: '',
domain: '{organization}.example.com',
models: '*',
},
},
src/main.ts
import { createDomainRouteResolver } from '@rhino-dev/rhino-nestjs';

app.use(createDomainRouteResolver({ prisma, config }));
applyRhinoRouting(app, { prefix: 'api' });

Routes:

GET    https://acme.example.com/api/posts
POST https://acme.example.com/api/posts

The captured {organization} subdomain feeds organization resolution exactly like the :organization path prefix. See Route Groups → Domain Constraints.

ResolveOrganizationMiddleware​

This middleware handles tenant resolution from the :organization route parameter:

  1. Extracts the organization parameter from the route
  2. Looks up the Organization model by the configured organizationIdentifierColumn
  3. Verifies the authenticated user belongs to the organization
  4. Sets req.organization for downstream controllers and policies
import { ResolveOrganizationMiddleware } from '@rhino-dev/rhino-nestjs';

If the organization is not found, a 404 is returned. If the user does not belong to the organization, a 404 is returned (to avoid leaking the existence of organizations).

Organization Scoping (belongsToOrganization)​

Set belongsToOrganization: true on a model registration to enable automatic organization scoping. The model's Prisma definition should include an organizationId column and an organization relation:

prisma/schema.prisma
model Post {
id Int @id @default(autoincrement())
organizationId Int
// ...
organization Organization @relation(fields: [organizationId], references: [id])

@@map("posts")
}
src/rhino.config.ts
posts: {
model: 'post',
belongsToOrganization: true,
},

What it Does​

Auto-sets organizationId on create: When an organization is present in the request context, Rhino sets organizationId from req.organization.id on create. Client-supplied organizationId is stripped from the input — the org always comes from the resolved request context.

Scopes every query: When an organization is present, Rhino filters each query to that organization (WHERE organizationId = ?). Outside an HTTP request (scripts, tests, queue workers), no organization is set, so scoping is skipped.

Nested Organization Scoping​

Not every model has a direct organizationId column. For child models, set owner to the relation Rhino should walk to find the organization:

src/rhino.config.ts
comments: {
model: 'comment',
owner: 'post', // Comment -> post -> organization
},

Rhino traverses Comment -> post to find the organization using a nested Prisma relation filter, equivalent to:

SELECT * FROM comments
WHERE EXISTS (
SELECT 1 FROM posts WHERE posts.id = comments.post_id AND posts.organization_id = ?
);

Deeper chains are also supported (e.g., Reply -> comment -> post -> organization).

Organization Scope Precedence​

The controller applies organization scoping using the following order of precedence:

  1. Resource IS the Organization model -- restrict to the current org's primary key
  2. Model has an organizationId column -- simple WHERE organizationId = ?
  3. owner chain is configured / auto-detected -- walk the named relation(s) to a model with organizationId and filter via a nested relation condition
  4. No relationship found -- model is global (no scope applied)

Auto-Setting Organization on Create​

When creating a record via POST /api/:organization/posts, the controller automatically adds organizationId to the data if:

  • An organization is present in the request context (set by middleware)
  • The registration has belongsToOrganization: true and the model has an organizationId column

Client-supplied organizationId is always stripped from the input first — the value comes from the resolved request context, never from the request body.

Membership Verification​

ResolveOrganizationMiddleware verifies that the authenticated user belongs to the resolved organization by checking the user-organization membership model (userOrganizationModel, default userRole) for a row matching the user and organization. If no membership row is found, the middleware denies access (404 to avoid leaking which organizations exist).

Group Membership Enforcement​

By default, belonging to an organization is what grants access; a route group is not itself an access boundary. You can opt into treating group membership as a first-class gate with the master flag on auth:

src/rhino.config.ts
RhinoModule.forRoot({
auth: { enforceGroupMembership: false }, // default OFF — behavior unchanged
// ...
})

When the flag is on, after authentication an additional coarse check runs before permissions: the user must hold a user_roles row whose route_group matches the request's group (a NULL/absent route_group row is a wildcard that matches every group) and, for tenant groups, the resolved organization. No matching row → 403. Permissions then resolve from that matching membership row (per (group, org)) instead of the org-presence heuristic.

This pairs with the per-group auth/hooks keys and invitation route_group described in Route Groups → Group membership & auth. With the flag off, none of this applies and multi-tenancy behaves exactly as documented above.

Why 404 and not 403?

With enforceGroupMembership off (the default), an authenticated user who hits an organization they don't belong to gets a 404 — this prevents leaking which organization slugs exist. A genuinely unknown org always 404s.

When enforceGroupMembership is on, this changes for an authenticated non-member of the requested route group: the membership gate runs before the org-resolution 404 and returns 403 (membership denial takes precedence over the org 404). The gate resolves the org itself as needed, so a real org you simply aren't a member of yields 403, while a genuinely non-existent org still 404s.