Contrôleur de Multi-Locataire Aegis

VérifiéSûr

Garantit que chaque requête MongoDB intègre obligatoirement le champ communityId, en extrayant cette information du jeton JWT plutôt que du corps de la requête. Ce skill s'applique lors de l'écriture de requêtes, de méthodes de repository ou de logique de service dans Aegis, afin d'éviter tout accès inter-communauté non autorisé.

Spar Skills Guide Bot
DeveloppementIntermédiaire
8002/06/2026
Claude CodeCursorWindsurf
#multi-tenancy#mongodb#security#nestjs#aegis

Recommandé pour

Notre avis

Cette compétence garantit que toutes les requêtes MongoDB incluent obligatoirement le champ `communityId` extrait du token JWT, afin de prévenir les fuites de données entre locataires.

Points forts

  • Piste claire pour extraire le communityId depuis le token JWT et le propager jusqu'aux repositories.
  • Liste détaillée des patterns corrects pour les méthodes find, update, delete, et les index.
  • Identifie et interdit explicitement les anti-patterns courants comme les requêtes globales ou le communityId optionnel.

Limites

  • Ne couvre pas la gestion des accès inter-communautés légitimes (par ex. administrateur système).
  • Suppose que le JWT contient toujours un communityId valide, sans cas de test ou fallback.
  • N'aborde pas les performances ou l'impact sur les index composés.
Quand l'utiliser

Lors du développement de nouvelles routes, services ou repositories dans une application NestJS multi-locataire utilisant MongoDB.

Quand l'éviter

Pour des projets mono-locataire, des APIs publiques sans isolation par communauté, ou si le mécanisme d'authentification ne repose pas sur JWT.

Analyse de sécurité

Sûr
Score qualité95/100

This 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.

Aucun point d'attention détecté

Exemples

Create a multi-tenant repository method
Write a repository method for Payment model that finds payments by household ID, ensuring multi-tenancy by including communityId from the JWT context.
Fix a missing communityId query
The following repository method lacks multi-tenancy enforcement: `async findAll() { return this.model.find({}); }`. Rewrite it to include communityId from the token.
Add communityId to an existing index
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 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
Skills similaires