1
0

feat(ee): add multi-organization workspaces feature

- Add `user_tenants` system DB migration for many-to-many user-to-org relationship
- Add backfill migration to populate existing users into join table
- Add `UserTenant` Objection.js system model and register globally
- Enforce org membership validation in `TenancyGlobalGuard` (security)
- Add `modules/ee/Workspaces` with full CRUD: create, list, delete, build-status
- Add `CreateUserTenantOnSignupSubscriber` for backward-compatible signup flow
- Register `WorkspacesModule` in `AppModule`

API endpoints:
  GET  /workspaces              - list all orgs user belongs to
  POST /workspaces              - create new org (async build)
  GET  /workspaces/build/:jobId - poll build job status
  DELETE /workspaces/:orgId     - delete org (owner only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ahmed Bouhuolia
2026-03-20 16:49:54 +02:00
parent cfbfc0b746
commit e968cf646c
15 changed files with 477 additions and 11 deletions
@@ -0,0 +1,26 @@
exports.up = function (knex) {
return knex.schema.createTable('user_tenants', (table) => {
table.bigIncrements('id');
table
.integer('user_id')
.unsigned()
.notNullable()
.references('id')
.inTable('users')
.onDelete('CASCADE');
table
.bigInteger('tenant_id')
.unsigned()
.notNullable()
.references('id')
.inTable('tenants')
.onDelete('CASCADE');
table.string('role', 20).notNullable().defaultTo('owner');
table.timestamps();
table.unique(['user_id', 'tenant_id']);
});
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('user_tenants');
};
@@ -0,0 +1,13 @@
exports.up = function (knex) {
return knex.raw(`
INSERT IGNORE INTO user_tenants (user_id, tenant_id, role, created_at, updated_at)
SELECT id, tenant_id, 'owner', NOW(), NOW()
FROM users
WHERE tenant_id IS NOT NULL
`);
};
exports.down = function () {
// Cannot safely reverse a backfill.
return Promise.resolve();
};
@@ -81,6 +81,7 @@ import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module';
import { RolesModule } from '../Roles/Roles.module';
import { SubscriptionModule } from '../Subscription/Subscription.module';
import { OrganizationModule } from '../Organization/Organization.module';
import { WorkspacesModule } from '../ee/Workspaces/Workspaces.module';
import { TenantDBManagerModule } from '../TenantDBManager/TenantDBManager.module';
import { PaymentServicesModule } from '../PaymentServices/PaymentServices.module';
import { AuthModule } from '../Auth/Auth.module';
@@ -243,6 +244,7 @@ import { AppThrottleModule } from './AppThrottle.module';
RolesModule,
SubscriptionModule,
OrganizationModule,
WorkspacesModule,
TenantDBManagerModule,
PaymentServicesModule,
LoopsModule,
@@ -7,9 +7,10 @@ import { SystemKnexConnection } from '../SystemDB/SystemDB.constants';
import { SystemModelsConnection } from './SystemModels.constants';
import { SystemUser } from '../models/SystemUser';
import { TenantMetadata } from '../models/TenantMetadataModel';
import { UserTenant } from '../models/UserTenant.model';
import { TenantRepository } from '../repositories/Tenant.repository';
const models = [SystemUser, PlanSubscription, TenantModel, TenantMetadata];
const models = [SystemUser, PlanSubscription, TenantModel, TenantMetadata, UserTenant];
const modelProviders = models.map((model) => {
return {
@@ -0,0 +1,34 @@
import { Model } from 'objection';
import { BaseModel } from '@/models/Model';
import { TenantModel } from './TenantModel';
export type UserTenantRole = 'owner' | 'member';
export class UserTenant extends BaseModel {
public userId: number;
public tenantId: number;
public role: UserTenantRole;
public tenant: TenantModel;
static get tableName() {
return 'user_tenants';
}
static get relationMappings() {
const { SystemUser } = require('./SystemUser');
const { TenantModel } = require('./TenantModel');
return {
user: {
relation: Model.BelongsToOneRelation,
modelClass: SystemUser,
join: { from: 'user_tenants.userId', to: 'users.id' },
},
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: TenantModel,
join: { from: 'user_tenants.tenantId', to: 'tenants.id' },
},
};
}
}
@@ -1,14 +1,17 @@
import { isEmpty } from 'lodash';
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
SetMetadata,
Inject,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ClsService } from 'nestjs-cls';
import { IS_PUBLIC_ROUTE } from '../Auth/Auth.constants';
import { getAuthApiKey } from '../Auth/Auth.utils';
import { UserTenant } from '../System/models/UserTenant.model';
import { TenantModel } from '../System/models/TenantModel';
export const IS_TENANT_AGNOSTIC = 'IS_TENANT_AGNOSTIC';
@@ -16,33 +19,61 @@ export const TenantAgnosticRoute = () => SetMetadata(IS_TENANT_AGNOSTIC, true);
@Injectable()
export class TenancyGlobalGuard implements CanActivate {
constructor(private reflector: Reflector) {}
constructor(
private reflector: Reflector,
private readonly cls: ClsService,
@Inject(UserTenant.name)
private readonly userTenantModel: typeof UserTenant,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
) {}
/**
* Validates the organization ID in the request headers.
* @param {ExecutionContext} context
* @returns {boolean}
* Validates the organization ID in the request headers and enforces
* that the authenticated user is a member of the requested organization.
*/
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const organizationId = request.headers['organization-id'];
const authorization = request.headers['authorization']?.trim();
const isAuthApiKey = !!getAuthApiKey(authorization || '');
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_ROUTE,
[context.getHandler(), context.getClass()],
);
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_ROUTE, [
context.getHandler(),
context.getClass(),
]);
const isTenantAgnostic = this.reflector.getAllAndOverride<boolean>(
IS_TENANT_AGNOSTIC,
[context.getHandler(), context.getClass()],
);
if (isPublic || isTenantAgnostic || isAuthApiKey) {
return true;
}
if (!organizationId) {
throw new UnauthorizedException('Organization ID is required.');
}
// Validate that the authenticated user is a member of the requested organization.
const userId = this.cls.get<number>('userId');
const tenant = await this.tenantModel.query().findOne({ organizationId });
if (!tenant) {
throw new UnauthorizedException('Organization not found.');
}
const membership = await this.userTenantModel
.query()
.findOne({ userId, tenantId: tenant.id });
if (!membership) {
throw new UnauthorizedException(
'You do not have access to this organization.',
);
}
return true;
}
}
@@ -0,0 +1,95 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ClsService } from 'nestjs-cls';
import { TenantAgnosticRoute } from '@/modules/Tenancy/TenancyGlobal.guard';
import { IgnoreUserVerifiedRoute } from '@/modules/Auth/guards/EnsureUserVerified.guard';
import { IgnoreTenantInitializedRoute } from '@/modules/Tenancy/EnsureTenantIsInitialized.guard';
import { IgnoreTenantSeededRoute } from '@/modules/Tenancy/EnsureTenantIsSeeded.guards';
import { IgnoreTenantModelsInitialize } from '@/modules/Tenancy/TenancyInitializeModels.guard';
import { CreateWorkspaceService } from './commands/CreateWorkspace.service';
import { DeleteWorkspaceService } from './commands/DeleteWorkspace.service';
import { GetWorkspacesService } from './queries/GetWorkspaces.service';
import { GetWorkspaceBuildJobService } from './queries/GetWorkspaceBuildJob.service';
import { CreateWorkspaceDto } from './dtos/CreateWorkspace.dto';
import {
CreateWorkspaceResponseDto,
WorkspaceDto,
} from './dtos/WorkspaceResponse.dto';
@ApiTags('Workspaces')
@Controller('workspaces')
export class WorkspacesController {
constructor(
private readonly createWorkspaceService: CreateWorkspaceService,
private readonly deleteWorkspaceService: DeleteWorkspaceService,
private readonly getWorkspacesService: GetWorkspacesService,
private readonly getWorkspaceBuildJobService: GetWorkspaceBuildJobService,
private readonly cls: ClsService,
) {}
/**
* Lists all organizations the authenticated user belongs to.
* No `organization-id` header required.
*/
@Get()
@TenantAgnosticRoute()
@IgnoreUserVerifiedRoute()
@ApiOperation({ summary: 'List workspaces the authenticated user belongs to' })
async listWorkspaces(): Promise<WorkspaceDto[]> {
const userId = this.cls.get<number>('userId');
return this.getWorkspacesService.getWorkspaces(userId);
}
/**
* Creates a new workspace (organization) for the authenticated user.
* The organization database is built asynchronously via a background job.
* No `organization-id` header required.
*/
@Post()
@TenantAgnosticRoute()
@IgnoreUserVerifiedRoute()
@HttpCode(200)
@ApiOperation({ summary: 'Create a new workspace' })
async createWorkspace(
@Body() dto: CreateWorkspaceDto,
): Promise<CreateWorkspaceResponseDto> {
const userId = this.cls.get<number>('userId');
return this.createWorkspaceService.createWorkspace(userId, dto);
}
/**
* Deletes a workspace. Only the workspace owner is permitted to delete it.
* Requires `organization-id` header (must match the path param).
*/
@Delete(':organizationId')
@IgnoreTenantInitializedRoute()
@IgnoreTenantSeededRoute()
@IgnoreTenantModelsInitialize()
@HttpCode(200)
@ApiOperation({ summary: 'Delete a workspace (owner only)' })
async deleteWorkspace(
@Param('organizationId') organizationId: string,
): Promise<void> {
const userId = this.cls.get<number>('userId');
await this.deleteWorkspaceService.deleteWorkspace(userId, organizationId);
}
/**
* Polls the build job status for a workspace being provisioned.
* No `organization-id` header required.
*/
@Get('build/:buildJobId')
@TenantAgnosticRoute()
@ApiOperation({ summary: 'Get workspace build job status' })
async buildJobStatus(@Param('buildJobId') buildJobId: string) {
return this.getWorkspaceBuildJobService.getJobDetails(buildJobId);
}
}
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { WorkspacesController } from './Workspaces.controller';
import { CreateWorkspaceService } from './commands/CreateWorkspace.service';
import { DeleteWorkspaceService } from './commands/DeleteWorkspace.service';
import { GetWorkspacesService } from './queries/GetWorkspaces.service';
import { GetWorkspaceBuildJobService } from './queries/GetWorkspaceBuildJob.service';
import { CreateUserTenantOnSignupSubscriber } from './subscribers/CreateUserTenantOnSignup.subscriber';
import { OrganizationBuildQueue } from '@/modules/Organization/Organization.types';
import { InjectSystemModel } from '@/modules/System/SystemModels/SystemModels.module';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { TenantDBManagerModule } from '@/modules/TenantDBManager/TenantDBManager.module';
import { GetBuildOrganizationBuildJob } from '@/modules/Organization/commands/GetBuildOrganizationJob.service';
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
@Module({
imports: [
BullModule.registerQueue({ name: OrganizationBuildQueue }),
TenantDBManagerModule,
],
controllers: [WorkspacesController],
providers: [
InjectSystemModel(UserTenant),
TenantRepository,
CreateWorkspaceService,
DeleteWorkspaceService,
GetWorkspacesService,
GetWorkspaceBuildJobService,
CreateUserTenantOnSignupSubscriber,
GetBuildOrganizationBuildJob,
],
})
export class WorkspacesModule {}
@@ -0,0 +1,70 @@
import { Queue } from 'bullmq';
import { Inject, Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
import {
OrganizationBuildQueue,
OrganizationBuildQueueJob,
OrganizationBuildQueueJobPayload,
} from '@/modules/Organization/Organization.types';
import { transformBuildDto } from '@/modules/Organization/Organization.utils';
import { CreateWorkspaceDto } from '../dtos/CreateWorkspace.dto';
import { CreateWorkspaceResponseDto } from '../dtos/WorkspaceResponse.dto';
@Injectable()
export class CreateWorkspaceService {
constructor(
@Inject(UserTenant.name)
private readonly userTenantModel: typeof UserTenant,
private readonly tenantRepository: TenantRepository,
@InjectQueue(OrganizationBuildQueue)
private readonly organizationBuildQueue: Queue,
) {}
/**
* Creates a new workspace (organization) for the authenticated user.
* - Creates a new tenant row with a unique organizationId.
* - Links the user as owner via user_tenants.
* - Saves organization metadata.
* - Enqueues the tenant database build job.
*/
async createWorkspace(
userId: number,
dto: CreateWorkspaceDto,
): Promise<CreateWorkspaceResponseDto> {
// Create the new tenant row.
const tenant = await this.tenantRepository.createWithUniqueOrgId();
// Link the authenticated user as the owner of this new workspace.
await this.userTenantModel.query().insert({
userId,
tenantId: tenant.id,
role: 'owner',
});
// Transform and persist the organization metadata.
const transformedDto = transformBuildDto(dto);
await this.tenantRepository.saveMetadata(tenant.id, transformedDto);
// Enqueue the build job using the same queue and processor as the existing flow.
const jobMeta = await this.organizationBuildQueue.add(
OrganizationBuildQueueJob,
{
organizationId: tenant.organizationId,
userId,
buildDto: transformedDto,
} as OrganizationBuildQueueJobPayload,
);
// Mark the tenant as currently building.
await this.tenantRepository.markAsBuilding(jobMeta.id).findById(tenant.id);
return {
organizationId: tenant.organizationId,
jobId: jobMeta.id,
};
}
}
@@ -0,0 +1,51 @@
import { Inject, Injectable } from '@nestjs/common';
import { ServiceError } from '@/modules/Items/ServiceError';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { TenantRepository } from '@/modules/System/repositories/Tenant.repository';
import { TenantDBManager } from '@/modules/TenantDBManager/TenantDBManager';
const ERRORS = {
WORKSPACE_NOT_FOUND: 'WORKSPACE.NOT_FOUND',
NOT_WORKSPACE_OWNER: 'NOT.WORKSPACE.OWNER',
};
@Injectable()
export class DeleteWorkspaceService {
constructor(
@Inject(UserTenant.name)
private readonly userTenantModel: typeof UserTenant,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
private readonly tenantRepository: TenantRepository,
private readonly tenantDBManager: TenantDBManager,
) {}
/**
* Deletes a workspace (organization). Only the owner of the workspace
* is permitted to delete it.
* - Drops the physical tenant database.
* - Deletes the tenant row (cascades to user_tenants).
*/
async deleteWorkspace(userId: number, organizationId: string): Promise<void> {
const tenant = await this.tenantModel.query().findOne({ organizationId });
if (!tenant) {
throw new ServiceError(ERRORS.WORKSPACE_NOT_FOUND);
}
const membership = await this.userTenantModel
.query()
.findOne({ userId, tenantId: tenant.id });
if (!membership || membership.role !== 'owner') {
throw new ServiceError(ERRORS.NOT_WORKSPACE_OWNER);
}
// Drop the physical tenant database if it exists.
await this.tenantDBManager.dropDatabaseIfExists();
// Delete the tenant row — cascades to user_tenants via FK.
await this.tenantModel.query().deleteById(tenant.id);
}
}
@@ -0,0 +1,3 @@
import { BuildOrganizationDto } from '@/modules/Organization/dtos/Organization.dto';
export class CreateWorkspaceDto extends BuildOrganizationDto {}
@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class WorkspaceMetadataDto {
@ApiProperty() name: string;
@ApiProperty() baseCurrency: string;
@ApiPropertyOptional() industry?: string;
@ApiPropertyOptional() location?: string;
@ApiPropertyOptional() timezone?: string;
@ApiPropertyOptional() language?: string;
}
export class WorkspaceDto {
@ApiProperty() organizationId: string;
@ApiProperty() isReady: boolean;
@ApiProperty() isBuildRunning: boolean;
@ApiPropertyOptional() buildJobId?: string;
@ApiProperty() role: 'owner' | 'member';
@ApiPropertyOptional({ type: WorkspaceMetadataDto })
metadata?: WorkspaceMetadataDto;
}
export class CreateWorkspaceResponseDto {
@ApiProperty() organizationId: string;
@ApiProperty() jobId: string;
}
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { GetBuildOrganizationBuildJob } from '@/modules/Organization/commands/GetBuildOrganizationJob.service';
@Injectable()
export class GetWorkspaceBuildJobService {
constructor(
private readonly getBuildJobService: GetBuildOrganizationBuildJob,
) {}
/**
* Returns the current status of a workspace build job.
*/
getJobDetails(buildJobId: string) {
return this.getBuildJobService.getJobDetails(buildJobId);
}
}
@@ -0,0 +1,40 @@
import { Inject, Injectable } from '@nestjs/common';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto';
@Injectable()
export class GetWorkspacesService {
constructor(
@Inject(UserTenant.name)
private readonly userTenantModel: typeof UserTenant,
) {}
/**
* Returns all workspaces (organizations) the given user belongs to,
* including their metadata and build status.
*/
async getWorkspaces(userId: number): Promise<WorkspaceDto[]> {
const memberships = await this.userTenantModel
.query()
.where('userId', userId)
.withGraphFetched('tenant.metadata');
return memberships.map((m) => ({
organizationId: m.tenant.organizationId,
isReady: m.tenant.isReady,
isBuildRunning: m.tenant.isBuildRunning,
buildJobId: m.tenant.buildJobId ?? undefined,
role: m.role,
metadata: m.tenant.metadata
? {
name: m.tenant.metadata.name,
baseCurrency: m.tenant.metadata.baseCurrency,
industry: m.tenant.metadata.industry,
location: m.tenant.metadata.location,
timezone: m.tenant.metadata.timezone,
language: m.tenant.metadata.language,
}
: undefined,
}));
}
}
@@ -0,0 +1,26 @@
import { Inject, Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { IAuthSignedUpEventPayload } from '@/modules/Auth/Auth.interfaces';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
@Injectable()
export class CreateUserTenantOnSignupSubscriber {
constructor(
@Inject(UserTenant.name)
private readonly userTenantModel: typeof UserTenant,
) {}
/**
* On user sign-up, create a user_tenants record linking the new user
* to their new organization as the owner.
*/
@OnEvent(events.auth.signUp)
async handleSignUp({ user, tenant }: IAuthSignedUpEventPayload): Promise<void> {
await this.userTenantModel.query().insert({
userId: user.id,
tenantId: tenant.id,
role: 'owner',
});
}
}