--- name: tenancy-enforcer description: Use this skill when writing MongoDB queries, repository methods, or service logic in Aegis. It enforces strict multi-tenancy by ensuring communityId is always included. --- # 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 ```typescript // Controller extracts from token @Get() async findAll(@CurrentUser() user: JwtPayload) { return this.service.findAll(user.communityId); } // Service passes to repository async findAll(communityId: string) { return this.repository.findAll(communityId); } // Repository includes in query async findAll(communityId: string) { return this.model.find({ communityId: new Types.ObjectId(communityId), }); } ``` ### Wrong: From Request Body ```typescript // NEVER DO THIS @Post() async create(@Body() dto: CreateDto) { // dto.communityId could be forged by attacker return this.service.create(dto.communityId, dto); } ``` ## Repository Method Patterns ### Find Methods ```typescript // Always require communityId as parameter async findById(id: string, communityId: string): Promise { return this.model.findOne({ _id: new Types.ObjectId(id), communityId: new Types.ObjectId(communityId), // REQUIRED }); } async findByHousehold(householdId: string, communityId: string): Promise { return this.model.find({ householdId: new Types.ObjectId(householdId), communityId: new Types.ObjectId(communityId), // REQUIRED }); } ``` ### Update Methods ```typescript async updateById( id: string, communityId: string, data: UpdateDto, ): Promise { return this.model.findOneAndUpdate( { _id: new Types.ObjectId(id), communityId: new Types.ObjectId(communityId), // REQUIRED in filter }, { $set: data }, { new: true }, ); } ``` ### Delete Methods ```typescript async deleteById(id: string, communityId: string): Promise { const result = await this.model.deleteOne({ _id: new Types.ObjectId(id), communityId: new Types.ObjectId(communityId), // REQUIRED }); return result.deletedCount > 0; } ``` ## Common Anti-Patterns (FORBIDDEN) ### Global Queries ```typescript // WRONG - No communityId filter async findAll(): Promise { return this.model.find({}); // SECURITY BREACH } // WRONG - ID-only lookup async findById(id: string): Promise { return this.model.findById(id); // Can access ANY community's data } ``` ### Optional communityId ```typescript // WRONG - communityId should never be optional async findById(id: string, communityId?: string): Promise { const filter: any = { _id: new Types.ObjectId(id) }; if (communityId) { filter.communityId = new Types.ObjectId(communityId); } return this.model.findOne(filter); // Dangerous when omitted } ``` ### Trusting Request Body ```typescript // WRONG - Request body can be forged @Post() async create(@Body() dto: CreatePaymentDto) { return this.paymentService.create({ ...dto, communityId: dto.communityId, // Attacker can set any communityId }); } // CORRECT - Use JWT context @Post() async create( @Body() dto: CreatePaymentDto, @CurrentUser() user: JwtPayload, ) { return this.paymentService.create({ ...dto, communityId: user.communityId, // From authenticated token }); } ``` ## Index Patterns All compound indexes MUST have `communityId` as the first field for query efficiency and logical isolation. ```typescript // CORRECT - communityId first @Schema() export class Payment { // ... } PaymentSchema.index({ communityId: 1, householdId: 1 }); PaymentSchema.index({ communityId: 1, state: 1 }); PaymentSchema.index({ communityId: 1, householdId: 1, billingPeriodStart: 1 }, { unique: true }); // WRONG - communityId not first PaymentSchema.index({ householdId: 1, communityId: 1 }); // Query won't use index efficiently PaymentSchema.index({ state: 1 }); // Global index, no tenant isolation ``` ## Aggregate Pipeline Rules ```typescript // CORRECT - $match with communityId as first stage async aggregateByHousehold(communityId: string) { return this.model.aggregate([ { $match: { communityId: new Types.ObjectId(communityId), // FIRST stage }, }, { $group: { _id: '$householdId', total: { $sum: '$amount' }, }, }, ]); } // WRONG - Missing communityId or not first async aggregateAll() { return this.model.aggregate([ { $group: { _id: '$householdId', total: { $sum: '$amount' } } }, // No tenant filter ]); } ``` ## Service Layer Enforcement Services should always require communityId from controllers, never have default values or lookups. ```typescript // CORRECT - Explicit communityId parameter @Injectable() export class PaymentService { async findByHousehold(householdId: string, communityId: string) { return this.repository.findByHousehold(householdId, communityId); } } // WRONG - Fetching communityId from related entity @Injectable() export class PaymentService { async findByHousehold(householdId: string) { const household = await this.householdService.findById(householdId); // What if household doesn't exist? What if wrong community? return this.repository.findByHousehold(householdId, household.communityId); } } ``` ## Exception: Community Collection The `Community` collection itself does not have a `communityId` field - it IS the tenant root. When querying communities: ```typescript // Community queries use _id directly (admin operations only) async findCommunityById(id: string): Promise { return this.communityModel.findById(id); } ``` ## Verification Checklist Before committing any repository/service code: - [ ] Every `find()` call includes `communityId` in filter - [ ] Every `findOne()` call includes `communityId` in filter - [ ] Every `updateOne()`/`updateMany()` includes `communityId` in filter - [ ] Every `deleteOne()`/`deleteMany()` includes `communityId` in filter - [ ] All aggregate pipelines have `$match` with `communityId` as first stage - [ ] `communityId` comes from JWT/controller, not request body - [ ] No methods have optional `communityId` parameter - [ ] New indexes have `communityId` as first field in compound indexes