Multi-Tenancy and RBAC¶
This guide explains how Farm handles multi-tenancy through Organizations, how the RBAC model works across global roles, org roles, and granular permissions, 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, documentation pages, API specs, gateway routes, 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, AuditLog, Documentation, ApiSpec, and GatewayRoute is nullable and indexed. A null value means the resource is not scoped to any organization.
erDiagram
User ||--o{ UserOrganization : belongs
UserOrganization }|--|| Organization : belongs
Organization ||--o{ Component : owns
Organization ||--o{ Team : owns
Organization ||--o{ Environment : owns
Organization ||--o{ Documentation : owns
Organization ||--o{ ApiSpec : owns
Organization ||--o{ GatewayRoute : owns
Organization ||--o{ AuditLog : owns
User {
uuid id PK
string username
string email
}
Organization {
uuid id PK
string slug
}
UserOrganization {
uuid userId FK
uuid orgId FK
enum role
} Organization Lifecycle¶
flowchart LR
A["POST /api/v1/organizations<br/>Create org"] --> B["Invite members"]
B --> C["Assign roles<br/>(OrgRole enum)"]
C --> D["Scope resources<br/>(X-Organization-Id header)"]
style A fill:#e1f5fe
style D fill:#e8f5e9 - 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, documentation, API specs, gateway routes) 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.
RBAC Model¶
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:
flowchart TD
subgraph "Tier 1: Global Roles"
SA["Super Admin<br/>Full platform access"]
Admin["Admin<br/>Platform management"]
User["User<br/>Basic access"]
end
subgraph "Tier 2: Org Roles"
Owner["Owner<br/>Org management"]
Admin2["Admin<br/>Org administration"]
Member["Member<br/>Standard access"]
Viewer["Viewer<br/>Read-only access"]
end
subgraph "Tier 3: Permissions"
P1["Permission.CATALOG_READ"]
P2["Permission.CATALOG_CREATE"]
P3["Permission.DEPLOYMENT_CREATE"]
P4["Permission.TEAM_MANAGE"]
P5["... (36 permissions total)"]
end
SA -.-> Admin
Admin -.-> User
Owner --> Admin2
Admin2 --> Member
Member --> Viewer 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(OrgRolesGuard)
@OrgRoles('admin')
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateOrganizationDto) { ... }
Combining both tiers (global admin bypasses org role check):
@UseGuards(RolesGuard, OrgRolesGuard)
@Roles('admin')
@OrgRoles('member')
@Get()
findAll(@Req() req: RequestWithOrg) { ... }
Tier 2 in Practice¶
OrgRolesGuard is used for organization management operations (CRUD on the org itself and its members). For resource endpoints (catalog, pipelines, teams, environments), the fine-grained permission model (Tier 3) is used instead.
Tier 3 — Granular Permissions (Phase 46)¶
Phase 46 introduced a fine-grained permission model layered on top of org roles. Instead of checking the raw org role, resource endpoints check specific named permissions resolved from the role.
Permission enum (defined in @farm/types):
| Permission | Granted to |
|---|---|
CATALOG_WRITE | admin, owner |
CATALOG_DELETE | owner |
TEAM_MANAGE | admin, owner |
PIPELINE_TRIGGER | admin, owner |
PIPELINE_DELETE | owner |
RolePermissions maps each OrgRole to its list of granted Permission values. Both are exported from @farm/types for use in the frontend.
Guard chain for resource endpoints:
@UseGuards(OrgRequiredGuard, PermissionGuard)
@RequiresPermission(Permission.CATALOG_WRITE)
@Post()
create(@Req() req: RequestWithOrg, @Body() dto: CreateComponentDto) { ... }
OrgRequiredGuard— readsX-Organization-Id, validates membership, setsreq.organizationIdandreq.orgRole; throws 403 (ForbiddenException) if the header is missing or the user is not a member, with the response message and anyerrorCodecoming from the thrown forbidden errorPermissionGuard— reads@RequiresPermission()metadata and checksreq.orgRoleagainstRolePermissions
Frontend RBAC:
import { usePermission } from "@/hooks/use-permission";
import { Permission } from "@farm/types";
function CatalogActions() {
const canWrite = usePermission(Permission.CATALOG_WRITE);
return canWrite ? <RegisterButton /> : null;
}
usePermission() reads orgRole from OrganizationContext and checks it against RolePermissions. Returns false while the role is loading.
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. - Fetches the current user's org role from
GET /v1/organizations/:id/members/mewhencurrentOrgchanges, exposing it asorgRole: OrgRole | null.
Use usePermission(permission) from apps/web/src/hooks/use-permission.ts to gate write-action UI elements based on the current user's org role:
import { usePermission } from "@/hooks/use-permission";
import { Permission } from "@farm/types";
const canWrite = usePermission(Permission.CATALOG_WRITE);
return canWrite ? <RegisterButton /> : null;
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 | When to use |
|---|---|---|
JwtAuthGuard | global (APP_GUARD) | All authenticated endpoints; verifies JWT and populates req.user |
RolesGuard | @Roles('admin') | Global admin-only operations not scoped to an organization |
OrgRolesGuard | @OrgRoles('admin') | Organization management routes (org CRUD, member management) |
OrgRequiredGuard | — | Org-scoped resource endpoints; validates membership and sets req.orgRole |
PermissionGuard | @RequiresPermission(Permission.X) | Fine-grained permission checks on resource endpoints (Phase 46) |
Never combine RolesGuard and PermissionGuard on the same endpoint — they serve different concerns.