Multi-Tenancy and RBAC¶
This guide explains how Farm handles multi-tenancy through Organizations, how the two-tier RBAC model works, and how to integrate these features in both backend and frontend code.
Overview¶
Farm uses a multi-tenant model where a single deployment serves multiple isolated organizations. The key relationships are:
- One User can belong to many Organizations, each with a distinct role.
- One Organization owns many resources: components, teams, environments, and audit log entries.
- Resources without an organization affiliation remain accessible to all authenticated users (backward-compatible with pre-multi-tenancy data).
The organizationId foreign key on Component, Team, Environment, and AuditLog is nullable and indexed. A null value means the resource is not scoped to any organization.
Organization Lifecycle¶
Create organization --> Invite members --> Assign roles --> Scope resources
(POST /api/v1/organizations) (OrgRole enum) (X-Organization-Id header)
- A user creates an organization. The creator is automatically assigned the
OWNERrole. - The owner or an admin adds members to the organization.
- Resources (components, teams, environments) created while an org context is active are tagged with
organizationId. - Subsequent requests include the
X-Organization-Idheader to operate within the organization's scope.
Two-Tier RBAC¶
Tier 1 - Global Roles¶
Global roles are stored as a string[] on the User entity and included in the JWT payload under the roles claim. They are enforced by RolesGuard using the @Roles() decorator.
| Role | Description |
|---|---|
admin | Full platform access; bypasses org-level restrictions |
user | Standard access; subject to org-level permissions |
Example:
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@Delete(':id')
remove(@Param('id') id: string) { ... }
Tier 2 - Org Roles¶
Org roles are stored in the UserOrganization join table and resolved at request time from the database. They are enforced by OrgRolesGuard using the @OrgRoles() decorator.
| Role | Numeric Weight | Capabilities |
|---|---|---|
OWNER | 3 | Full control: update org, delete org, manage all members and roles |
ADMIN | 2 | Manage members, update org settings, manage org resources |
MEMBER | 1 | Read and contribute access to org resources |
The guard performs a minimum-weight check: @OrgRoles("admin") allows both ADMIN (2) and OWNER (3).
Example:
@UseGuards(JwtAuthGuard, OrgRolesGuard)
@OrgRoles('admin')
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateOrganizationDto) { ... }
Combining both tiers (global admin bypasses org role check):
@UseGuards(JwtAuthGuard, RolesGuard, OrgRolesGuard)
@Roles('admin')
@OrgRoles('member')
@Get()
findAll(@Req() req: RequestWithOrg) { ... }
Creating an Organization¶
POST /api/v1/organizations
Authorization: Bearer <access_token>
Content-Type: application/json
{
"name": "Acme Platform Team",
"slug": "acme-platform",
"description": "Platform engineering team for Acme Corp"
}
Response:
{
"id": "f3a2b1c0-...",
"name": "Acme Platform Team",
"slug": "acme-platform",
"description": "Platform engineering team for Acme Corp",
"ownerId": "a1b2c3d4-...",
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
The calling user is automatically assigned the OWNER role in this organization.
Managing Members¶
List organizations for the current user¶
Get a specific organization¶
Update an organization (requires ADMIN or OWNER)¶
PATCH /api/v1/organizations/:id
Authorization: Bearer <access_token>
X-Organization-Id: f3a2b1c0-...
Content-Type: application/json
{
"description": "Updated description"
}
Delete an organization (requires OWNER)¶
DELETE /api/v1/organizations/:id
Authorization: Bearer <access_token>
X-Organization-Id: f3a2b1c0-...
OrgContextInterceptor¶
OrgContextInterceptor is registered globally as APP_INTERCEPTOR in AppModule. It executes on every incoming request before the controller handler runs.
Processing logic:
- Read the
X-Organization-Idheader from the incoming request. - If the header is absent or the user is unauthenticated: set
req.organizationId = undefinedand continue. This preserves backward compatibility with clients that do not send the header. - If the header is present and the user is authenticated: query the
UserOrganizationrepository for a record matching{ userId, organizationId }. - If a membership record is found: attach
req.organizationIdfor downstream use. - If a membership record is not found: throw
ForbiddenException("Not a member of this organization").
Interface:
Controllers and services receive the organization context via the RequestWithOrg interface:
// apps/api/src/common/interfaces/request-with-org.interface.ts
export interface RequestWithOrg extends Request {
organizationId?: string;
user?: {
userId: string;
username: string;
roles: string[];
};
}
Do not read organizationId from query parameters. Always use req.organizationId as injected by the interceptor.
Query Scoping¶
When req.organizationId is set, service findAll() methods scope their TypeORM queries to that organization:
// Example from CatalogService
async findAll(organizationId?: string): Promise<Component[]> {
const where = organizationId ? { organizationId } : {};
return this.componentRepository.find({ where });
}
Resources with organizationId = null are not returned when an org context is active, ensuring tenant isolation. Resources created outside an org context retain organizationId = null and remain accessible to non-scoped queries.
API Usage Examples¶
Request without org context (backward-compatible)¶
Returns all components accessible to the user, regardless of org affiliation:
Request with org context¶
Returns only components belonging to the specified organization:
If the user is not a member of that organization, the interceptor returns:
{
"statusCode": 403,
"timestamp": "2025-01-01T00:00:00.000Z",
"path": "/api/v1/catalog/components",
"message": "Not a member of this organization"
}
Frontend Integration¶
OrganizationProvider¶
OrganizationProvider (apps/web/src/contexts/organization-context.tsx) wraps the application and:
- Fetches the list of organizations the current user belongs to on mount.
- Persists the selected organization ID in
sessionStorageunder the keyfarm_current_org(plain string). - Exposes
switchOrg(id)to change the active organization andrefreshOrgs()to reload the list.
Automatic Header Injection¶
The API client (apps/web/src/lib/api-client.ts) reads the current org ID from sessionStorage and appends the X-Organization-Id header to every outbound request:
const currentOrg = typeof window !== 'undefined'
? sessionStorage.getItem('farm_current_org')
: null;
if (currentOrg) {
headers['X-Organization-Id'] = currentOrg;
}
This behavior is:
- SSR-safe: guarded by
typeof window !== 'undefined'. - Non-blocking: the header is omitted when no organization is selected.
OrgSwitcher Component¶
The OrgSwitcher dropdown in the sidebar lets users switch between their organizations. Selecting an organization calls switchOrg(), which updates sessionStorage and triggers a re-render so subsequent API calls include the new org context.
Organization Pages¶
| Route | Description |
|---|---|
/organizations | List all organizations for the current user |
/organizations/new | Create a new organization |
/organizations/[id] | View and manage a specific organization |
Guard Reference¶
| Guard | Decorator | Enforcement Layer |
|---|---|---|
JwtAuthGuard | (applied via @UseGuards) | Verifies JWT and populates req.user |
RolesGuard | @Roles('admin') | Global platform roles from JWT payload |
OrgRolesGuard | @OrgRoles('admin') | Org-level roles from UserOrganization table |