Aegis Multi-Tenancy Enforcer
When This Skill Applies
- Writing ANY MongoDB query (find, update, delete)
- Creating repository methods
- Implementing service layer logic
- Adding new indexes to schemas
- Creating aggregate pipelines
The Absolute Rule
Every database query MUST include communityId in the filter.
No exceptions. Cross-community access is a security breach.
Source of communityId
Correct: From JWT Token
typescript1// Controller extracts from token 2@Get() 3async findAll(@CurrentUser() user: JwtPayload) { 4 return this.service.findAll(user.communityId); 5} 6 7// Service passes to repository 8async findAll(communityId: string) { 9 return this.repository.findAll(communityId); 10} 11 12// Repository includes in query 13async findAll(communityId: string) { 14 return this.model.find({ 15 communityId: new Types.ObjectId(communityId), 16 }); 17}
Wrong: From Request Body
typescript1// NEVER DO THIS 2@Post() 3async create(@Body() dto: CreateDto) { 4 // dto.communityId could be forged by attacker 5 return this.service.create(dto.communityId, dto); 6}
Repository Method Patterns
Find Methods
typescript1// Always require communityId as parameter 2async findById(id: string, communityId: string): Promise<Document | null> { 3 return this.model.findOne({ 4 _id: new Types.ObjectId(id), 5 communityId: new Types.ObjectId(communityId), // REQUIRED 6 }); 7} 8 9async findByHousehold(householdId: string, communityId: string): Promise<Document[]> { 10 return this.model.find({ 11 householdId: new Types.ObjectId(householdId), 12 communityId: new Types.ObjectId(communityId), // REQUIRED 13 }); 14}
Update Methods
typescript1async updateById( 2 id: string, 3 communityId: string, 4 data: UpdateDto, 5): Promise<Document | null> { 6 return this.model.findOneAndUpdate( 7 { 8 _id: new Types.ObjectId(id), 9 communityId: new Types.ObjectId(communityId), // REQUIRED in filter 10 }, 11 { $set: data }, 12 { new: true }, 13 ); 14}
Delete Methods
typescript1async deleteById(id: string, communityId: string): Promise<boolean> { 2 const result = await this.model.deleteOne({ 3 _id: new Types.ObjectId(id), 4 communityId: new Types.ObjectId(communityId), // REQUIRED 5 }); 6 return result.deletedCount > 0; 7}
Common Anti-Patterns (FORBIDDEN)
Global Queries
typescript1// WRONG - No communityId filter 2async findAll(): Promise<Document[]> { 3 return this.model.find({}); // SECURITY BREACH 4} 5 6// WRONG - ID-only lookup 7async findById(id: string): Promise<Document | null> { 8 return this.model.findById(id); // Can access ANY community's data 9}
Optional communityId
typescript1// WRONG - communityId should never be optional 2async findById(id: string, communityId?: string): Promise<Document | null> { 3 const filter: any = { _id: new Types.ObjectId(id) }; 4 if (communityId) { 5 filter.communityId = new Types.ObjectId(communityId); 6 } 7 return this.model.findOne(filter); // Dangerous when omitted 8}
Trusting Request Body
typescript1// WRONG - Request body can be forged 2@Post() 3async create(@Body() dto: CreatePaymentDto) { 4 return this.paymentService.create({ 5 ...dto, 6 communityId: dto.communityId, // Attacker can set any communityId 7 }); 8} 9 10// CORRECT - Use JWT context 11@Post() 12async create( 13 @Body() dto: CreatePaymentDto, 14 @CurrentUser() user: JwtPayload, 15) { 16 return this.paymentService.create({ 17 ...dto, 18 communityId: user.communityId, // From authenticated token 19 }); 20}
Index Patterns
All compound indexes MUST have communityId as the first field for query efficiency and logical isolation.
typescript1// CORRECT - communityId first 2@Schema() 3export class Payment { 4 // ... 5} 6 7PaymentSchema.index({ communityId: 1, householdId: 1 }); 8PaymentSchema.index({ communityId: 1, state: 1 }); 9PaymentSchema.index({ communityId: 1, householdId: 1, billingPeriodStart: 1 }, { unique: true }); 10 11// WRONG - communityId not first 12PaymentSchema.index({ householdId: 1, communityId: 1 }); // Query won't use index efficiently 13PaymentSchema.index({ state: 1 }); // Global index, no tenant isolation
Aggregate Pipeline Rules
typescript1// CORRECT - $match with communityId as first stage 2async aggregateByHousehold(communityId: string) { 3 return this.model.aggregate([ 4 { 5 $match: { 6 communityId: new Types.ObjectId(communityId), // FIRST stage 7 }, 8 }, 9 { 10 $group: { 11 _id: '$householdId', 12 total: { $sum: '$amount' }, 13 }, 14 }, 15 ]); 16} 17 18// WRONG - Missing communityId or not first 19async aggregateAll() { 20 return this.model.aggregate([ 21 { $group: { _id: '$householdId', total: { $sum: '$amount' } } }, // No tenant filter 22 ]); 23}
Service Layer Enforcement
Services should always require communityId from controllers, never have default values or lookups.
typescript1// CORRECT - Explicit communityId parameter 2@Injectable() 3export class PaymentService { 4 async findByHousehold(householdId: string, communityId: string) { 5 return this.repository.findByHousehold(householdId, communityId); 6 } 7} 8 9// WRONG - Fetching communityId from related entity 10@Injectable() 11export class PaymentService { 12 async findByHousehold(householdId: string) { 13 const household = await this.householdService.findById(householdId); 14 // What if household doesn't exist? What if wrong community? 15 return this.repository.findByHousehold(householdId, household.communityId); 16 } 17}
Exception: Community Collection
The Community collection itself does not have a communityId field - it IS the tenant root. When querying communities:
typescript1// Community queries use _id directly (admin operations only) 2async findCommunityById(id: string): Promise<Community | null> { 3 return this.communityModel.findById(id); 4}
Verification Checklist
Before committing any repository/service code:
- Every
find()call includescommunityIdin filter - Every
findOne()call includescommunityIdin filter - Every
updateOne()/updateMany()includescommunityIdin filter - Every
deleteOne()/deleteMany()includescommunityIdin filter - All aggregate pipelines have
$matchwithcommunityIdas first stage -
communityIdcomes from JWT/controller, not request body - No methods have optional
communityIdparameter - New indexes have
communityIdas first field in compound indexes