fix: Corriger création salarié en mode staff avec employer_id

- Réorganiser la logique de sécurité pour distinguer staff/client
- Staff: accepter employer_id fourni et vérifier existence de l'org
- Client: utiliser l'org associée et bloquer toute tentative cross-org
- Améliorer les logs pour identifier le type d'utilisateur
- Corriger le retour no_organization inapproprié pour les staff
This commit is contained in:
odentas 2025-11-13 20:48:14 +01:00
parent 8cdd133d68
commit 2760ea2b39
2 changed files with 266 additions and 33 deletions

View file

@ -0,0 +1,174 @@
# Fix - Création de salarié en mode Staff
## 🐛 Problème identifié
En mode staff, lors de la création d'un nouveau salarié, l'erreur `no_organization` apparaissait même après avoir sélectionné une organisation dans le menu déroulant.
### Cause racine
La fonction `resolveActiveOrg()` retourne `null` pour les utilisateurs staff (comportement attendu), mais l'API `/api/salaries` POST rejetait toute demande avec un `null`, même quand l'utilisateur staff fournissait un `employer_id` valide dans le body de la requête.
La logique de sécurité ne distinguait pas :
- **Utilisateurs clients** : doivent avoir une organisation associée via `organization_members`
- **Utilisateurs staff** : peuvent créer des salariés pour n'importe quelle organisation en fournissant un `employer_id`
## ✅ Solution implémentée
### Modifications dans `/app/api/salaries/route.ts`
La logique de sécurité a été réorganisée pour gérer les deux cas différemment :
#### 1. Vérification du statut staff en premier
```typescript
// 🔒 SÉCURITÉ : Vérifier si l'utilisateur est staff
let isStaff = false;
try {
const { data: staffRow } = await sbAuth.from('staff_users').select('is_staff').eq('user_id', user.id).maybeSingle();
isStaff = !!staffRow?.is_staff;
} catch {
// Fallback sur metadata
const userMeta = user?.user_metadata || {};
const appMeta = user?.app_metadata || {};
isStaff = Boolean((userMeta.is_staff === true || userMeta.role === 'staff') || (Array.isArray(appMeta.roles) && appMeta.roles.includes('staff')));
}
```
#### 2. Logique différenciée selon le type d'utilisateur
**Pour les utilisateurs STAFF :**
```typescript
if (isStaff) {
// Accepter l'employer_id fourni dans la requête
orgId = body.employer_id || null;
if (!orgId) {
return NextResponse.json(
{ ok: false, error: 'no_organization', message: 'Vous devez sélectionner une organisation' },
{ status: 400 }
);
}
// Vérifier que l'organisation existe
const { data: orgExists } = await sbAuth
.from('organizations')
.select('id')
.eq('id', orgId)
.maybeSingle();
if (!orgExists) {
return NextResponse.json(
{ ok: false, error: 'invalid_organization', message: 'Organisation introuvable' },
{ status: 404 }
);
}
}
```
**Pour les utilisateurs CLIENTS :**
```typescript
else {
// Utiliser l'organisation associée à l'utilisateur
const userOrgId = await resolveActiveOrg(sbAuth);
if (!userOrgId) {
return NextResponse.json(
{ ok: false, error: 'no_organization', message: 'Aucune organisation associée à votre compte' },
{ status: 403 }
);
}
// 🔒 SÉCURITÉ CRITIQUE : Vérifier que l'employer_id fourni correspond à l'org de l'utilisateur
if (body.employer_id && body.employer_id !== userOrgId) {
return NextResponse.json(
{ ok: false, error: 'unauthorized_organization', message: 'Vous ne pouvez pas créer un salarié dans cette organisation' },
{ status: 403 }
);
}
orgId = userOrgId;
}
```
#### 3. Logs améliorés
```typescript
console.log('✅ [SÉCURITÉ] Vérifications réussies');
console.log(' - Utilisateur:', user.email);
console.log(' - Type:', isStaff ? 'STAFF' : 'CLIENT');
console.log(' - Organisation:', orgId);
console.log(' - Email salarié validé:', body.email_salarie);
console.log(' - Rate limit:', `${rateLimitMap.get(user.id)?.count || 0}/${RATE_LIMIT_MAX}`);
```
## 🔒 Sécurité
### Garanties maintenues
1. **Utilisateurs clients** : Ne peuvent créer des salariés **QUE** dans leur propre organisation
2. **Utilisateurs staff** : Peuvent créer des salariés pour n'importe quelle organisation, mais :
- Doivent obligatoirement sélectionner une organisation (pas de création sans org)
- L'organisation doit exister dans la base de données
3. **Rate limiting** : Toujours en place (50 créations/heure par utilisateur)
4. **Authentification** : Requise pour tous les utilisateurs
### Erreurs possibles
| Code d'erreur | Status | Cas |
|---------------|--------|-----|
| `unauthorized` | 401 | Utilisateur non authentifié |
| `rate_limit_exceeded` | 429 | Limite de 50 créations/heure dépassée |
| `no_organization` | 400 (staff) / 403 (client) | Aucune organisation sélectionnée ou associée |
| `invalid_organization` | 404 | Organisation sélectionnée introuvable (staff) |
| `unauthorized_organization` | 403 | Tentative de créer un salarié dans une autre org (client) |
## 🧪 Tests à effectuer
### Test 1 : Staff - Création avec organisation valide
```bash
# En tant que staff, sélectionner une organisation et créer un salarié
✓ Devrait réussir
✓ Le salarié devrait être associé à l'organisation sélectionnée
```
### Test 2 : Staff - Création sans sélectionner d'organisation
```bash
# En tant que staff, ne pas sélectionner d'organisation
✗ Devrait échouer avec "no_organization" (400)
```
### Test 3 : Staff - Création avec organisation inexistante
```bash
# Manuellement envoyer un employer_id invalide
✗ Devrait échouer avec "invalid_organization" (404)
```
### Test 4 : Client - Création normale
```bash
# En tant que client, créer un salarié
✓ Devrait réussir
✓ Le salarié devrait être dans l'organisation du client
```
### Test 5 : Client - Tentative de création dans une autre org
```bash
# Manuellement envoyer un employer_id d'une autre organisation
✗ Devrait échouer avec "unauthorized_organization" (403)
```
## 📝 Fichiers modifiés
- `/app/api/salaries/route.ts` - Logique de sécurité réorganisée
## 📝 Fichiers non modifiés (déjà corrects)
- `/app/(app)/salaries/nouveau/page.tsx` - Frontend déjà correct
- Charge la liste des organisations pour les staff
- Envoie `employer_id` quand staff ET organisation sélectionnée
- Validation client-side : `const orgOk = !isStaff || (isStaff && selectedOrg !== null);`
## ✨ Améliorations futures possibles
1. Ajouter un cache pour les vérifications staff (éviter une requête DB à chaque création)
2. Ajouter un audit log des créations de salariés par staff dans d'autres organisations
3. Permettre aux staff de créer des salariés dans plusieurs organisations en batch

View file

@ -309,6 +309,23 @@ export async function POST(req: NextRequest) {
);
}
// 🔒 SÉCURITÉ : Vérifier si l'utilisateur est staff
let isStaff = false;
try {
const { data: staffRow } = await sbAuth.from('staff_users').select('is_staff').eq('user_id', user.id).maybeSingle();
isStaff = !!staffRow?.is_staff;
} catch {
const userMeta = user?.user_metadata || {};
const appMeta = user?.app_metadata || {};
isStaff = Boolean((userMeta.is_staff === true || userMeta.role === 'staff') || (Array.isArray(appMeta.roles) && appMeta.roles.includes('staff')));
}
console.log('🔍 [SÉCURITÉ] Vérification utilisateur:', {
email: user.email,
isStaff,
employer_id_fourni: body.employer_id
});
// 🔒 SÉCURITÉ : Rate limiting (50 créations par heure par utilisateur)
const now = Date.now();
const userRateLimit = rateLimitMap.get(user.id);
@ -346,11 +363,51 @@ export async function POST(req: NextRequest) {
}
}
// 🔒 SÉCURITÉ : Récupérer l'organisation de l'utilisateur authentifié
// 🔒 SÉCURITÉ : Logique différente pour staff et clients
let orgId: string | null = null;
if (isStaff) {
// Pour les utilisateurs staff : accepter l'employer_id fourni
orgId = body.employer_id || null;
if (!orgId) {
console.error('❌ [STAFF] employer_id manquant dans la requête');
return NextResponse.json(
{
ok: false,
error: 'no_organization',
message: 'Vous devez sélectionner une organisation'
},
{ status: 400 }
);
}
// Vérifier que l'organisation existe
const { data: orgExists, error: orgError } = await sbAuth
.from('organizations')
.select('id')
.eq('id', orgId)
.maybeSingle();
if (orgError || !orgExists) {
console.error('❌ [STAFF] Organisation introuvable:', orgId);
return NextResponse.json(
{
ok: false,
error: 'invalid_organization',
message: 'Organisation introuvable'
},
{ status: 404 }
);
}
console.log('✅ [STAFF] Organisation validée:', orgId);
} else {
// Pour les utilisateurs clients : utiliser leur organisation associée
const userOrgId = await resolveActiveOrg(sbAuth);
if (!userOrgId) {
console.error('❌ [SÉCURITÉ] Aucune organisation trouvée pour l\'utilisateur:', user.id);
console.error('❌ [CLIENT] Aucune organisation trouvée pour l\'utilisateur:', user.id);
return NextResponse.json(
{
ok: false,
@ -362,11 +419,9 @@ export async function POST(req: NextRequest) {
}
// 🔒 SÉCURITÉ CRITIQUE : Vérifier que l'employer_id fourni (si présent) correspond à l'organisation de l'utilisateur
let orgId: string | null = body.employer_id || null;
if (orgId && orgId !== userOrgId) {
if (body.employer_id && body.employer_id !== userOrgId) {
console.error('❌ [SÉCURITÉ CRITIQUE] Tentative de création salarié dans une autre organisation!');
console.error(' - employer_id fourni:', orgId);
console.error(' - employer_id fourni:', body.employer_id);
console.error(' - organisation utilisateur:', userOrgId);
console.error(' - utilisateur:', user.email);
@ -383,8 +438,12 @@ export async function POST(req: NextRequest) {
// Utiliser l'organisation de l'utilisateur authentifié
orgId = userOrgId;
console.log('✅ [CLIENT] Organisation validée:', orgId);
}
console.log('✅ [SÉCURITÉ] Vérifications réussies');
console.log(' - Utilisateur:', user.email);
console.log(' - Type:', isStaff ? 'STAFF' : 'CLIENT');
console.log(' - Organisation:', orgId);
console.log(' - Email salarié validé:', body.email_salarie);
console.log(' - Rate limit:', `${rateLimitMap.get(user.id)?.count || 0}/${RATE_LIMIT_MAX}`)