Our review
This skill enforces strict multi-tenancy by ensuring every MongoDB query includes `communityId` from the JWT token, preventing cross-community data leaks.
Strengths
- Clear guidance on extracting communityId from JWT and passing it through controller, service, and repository layers.
- Comprehensive examples of correct patterns for find, update, delete operations and indexes.
- Explicitly forbids common anti-patterns like global queries, optional communityId, or trusting the request body.
Limitations
- Does not handle legitimate cross-community access (e.g., admin roles) or provide fallbacks.
- Assumes communityId is always present in JWT without covering validation or error handling.
- Lacks discussion on performance implications or index optimization beyond the first field rule.
When building or modifying NestJS services, repositories, or MongoDB queries in a multi-tenant application requiring data isolation per community.
For single-tenant applications, public APIs without community scoping, or when authentication does not rely on JWT tokens.
Security analysis
SafeThis skill provides code-level guidelines for enforcing multi-tenancy in MongoDB queries. It does not instruct any dangerous operations like file deletion, network calls, or data exfiltration. It is purely instructional text with example TypeScript code.
No concerns found
Examples
Write a repository method for Payment model that finds payments by household ID, ensuring multi-tenancy by including communityId from the JWT context.The following repository method lacks multi-tenancy enforcement: `async findAll() { return this.model.find({}); }`. Rewrite it to include communityId from the token.This Payment schema index only has `householdId`. Update it to be a compound index with communityId first for query efficiency.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
// 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
// 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
// Always require communityId as parameter
async findById(id: string, communityId: string): Promise<Document | null> {
return this.model.findOne({
_id: new Types.ObjectId(id),
communityId: new Types.ObjectId(communityId), // REQUIRED
});
}
async findByHousehold(householdId: string, communityId: string): Promise<Document[]> {
return this.model.find({
householdId: new Types.ObjectId(householdId),
communityId: new Types.ObjectId(communityId), // REQUIRED
});
}
Update Methods
async updateById(
id: string,
communityId: string,
data: UpdateDto,
): Promise<Document | null> {
return this.model.findOneAndUpdate(
{
_id: new Types.ObjectId(id),
communityId: new Types.ObjectId(communityId), // REQUIRED in filter
},
{ $set: data },
{ new: true },
);
}
Delete Methods
async deleteById(id: string, communityId: string): Promise<boolean> {
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
// WRONG - No communityId filter
async findAll(): Promise<Document[]> {
return this.model.find({}); // SECURITY BREACH
}
// WRONG - ID-only lookup
async findById(id: string): Promise<Document | null> {
return this.model.findById(id); // Can access ANY community's data
}
Optional communityId
// WRONG - communityId should never be optional
async findById(id: string, communityId?: string): Promise<Document | null> {
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
// 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.
// 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
// 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.
// 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:
// Community queries use _id directly (admin operations only)
async findCommunityById(id: string): Promise<Community | null> {
return this.communityModel.findById(id);
}
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
Next.js App Router Expert
Development
A skill that turns Claude into a Next.js App Router expert.
README Generator
Development
Creates professional and comprehensive README.md files for your projects.
API Documentation Writer
Development
Generates comprehensive API documentation in OpenAPI/Swagger format.