# Sécurité & Isolation POS

## 1. Isolation des Données par Company

### Principe Fondamental

**Chaque donnée POS appartient à exactement une Company. Aucune donnée ne peut être partagée ou visible entre companies.**

### Couches d'Isolation

```
┌─────────────────────────────────────────────────┐
│  Couche 1 : Route-level Security                │
│  #[IsGranted('ROLE_MANAGER')] sur Controller    │
├─────────────────────────────────────────────────┤
│  Couche 2 : CompanyVoter (Resource-level)       │
│  POS_ACCESS, POS_MANAGE, POS_SELL, etc.         │
├─────────────────────────────────────────────────┤
│  Couche 3 : Service-level Scoping               │
│  $company = $user->getPrimaryCompany()          │
│  Toutes les requêtes filtrent par company_id    │
├─────────────────────────────────────────────────┤
│  Couche 4 : Repository-level Filtering          │
│  WHERE company_id = :company dans chaque query  │
├─────────────────────────────────────────────────┤
│  Couche 5 : PosEmployeeRole (Permission-level)  │
│  Permissions granulaires par employé             │
├─────────────────────────────────────────────────┤
│  Couche 6 : PIN Caissier (Action-level)         │
│  Vérification PIN pour actions sensibles        │
├─────────────────────────────────────────────────┤
│  Couche 7 : Audit Trail (Traceability)          │
│  PosAuditLog + UserActivity pour tout tracer    │
└─────────────────────────────────────────────────┘
```

### Pattern Contrôleur POS

Chaque contrôleur POS manager DOIT suivre ce pattern :

```php
#[Route('/manager/pos/products')]
#[IsGranted('ROLE_MANAGER')]
class ProductController extends AbstractController
{
    #[Route('', name: 'manager_pos_products_index')]
    public function index(Request $request): Response
    {
        try {
            $user = $this->getUser();
            $company = $user->getPrimaryCompany();
            
            // OBLIGATOIRE : vérifier company
            if (!$company) {
                return $this->redirectToRoute('app_no_company');
            }
            
            // OBLIGATOIRE : vérifier POS activé
            if (!$this->posSettingsService->isPosEnabled($company)) {
                $this->addFlash('warning', 'Le module POS n\'est pas activé.');
                return $this->redirectToRoute('manager_dashboard');
            }
            
            // OBLIGATOIRE : voter check
            $this->denyAccessUnlessGranted('POS_MANAGE', $company);
            
            // Requête scopée par company
            $products = $this->productRepository->findByCompany($company, $filters);
            
            // ... render
        } catch (\Exception $e) {
            $this->fileLogger->error([...], true);
            // ...
        }
    }
}
```

### Pattern Repository POS

Chaque repository POS DOIT filtrer par company :

```php
class ProductRepository extends ServiceEntityRepository
{
    /**
     * TOUJOURS passer la Company en premier paramètre.
     */
    public function findByCompany(Company $company, array $filters = []): Query
    {
        $qb = $this->createQueryBuilder('p')
            ->where('p.company = :company')
            ->setParameter('company', $company);
        
        // Filtres additionnels...
        
        return $qb->getQuery();
    }
    
    /**
     * Vérifier l'appartenance avant accès.
     */
    public function findOneByIdAndCompany(int $id, Company $company): ?Product
    {
        return $this->findOneBy(['id' => $id, 'company' => $company]);
    }
}
```

---

## 2. Extension du CompanyVoter

### Nouveaux Attributs

```php
// Dans CompanyVoter.php
const POS_ACCESS = 'POS_ACCESS';         // Accès au module POS
const POS_MANAGE = 'POS_MANAGE';         // Gestion POS (CRUD)
const POS_SELL = 'POS_SELL';             // Droit de vendre
const POS_STOCK = 'POS_STOCK';           // Gestion stock
const POS_REPORTS = 'POS_REPORTS';       // Accès rapports
const POS_SETTINGS = 'POS_SETTINGS';     // Configuration POS

private const POS_ATTRIBUTES = [
    self::POS_ACCESS,
    self::POS_MANAGE,
    self::POS_SELL,
    self::POS_STOCK,
    self::POS_REPORTS,
    self::POS_SETTINGS,
];
```

### Matrice de Décision

```php
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
    $user = $token->getUser();
    $company = $subject; // Company entity
    
    // ROLE_ADMIN : accès lecture à tout
    if (in_array('ROLE_ADMIN', $user->getRoles())) {
        // Admin peut voir mais pas modifier la config POS d'une company
        return match ($attribute) {
            self::POS_SETTINGS => false, // Seul le manager configure
            default => true,
        };
    }
    
    // Vérifier que l'utilisateur appartient à la company
    if (!$this->userCompanyRepository->isUserInCompany($user, $company)) {
        return false;
    }
    
    // ROLE_MANAGER de cette company
    if (in_array('ROLE_MANAGER', $user->getRoles())) {
        return true; // Full access POS sur sa company
    }
    
    // ROLE_EMPLOYER : vérifier PosEmployeeRole
    if (in_array('ROLE_EMPLOYER', $user->getRoles())) {
        $posRole = $this->posEmployeeRoleRepository
            ->findOneBy(['user' => $user, 'company' => $company, 'isActive' => true]);
        
        if (!$posRole) {
            return false; // Pas de rôle POS assigné
        }
        
        return match ($attribute) {
            self::POS_ACCESS => true, // Tout employé POS a accès
            self::POS_SELL => in_array($posRole->getPosRole(), ['cashier', 'seller', 'supervisor']),
            self::POS_STOCK => $posRole->isCanManageStock(),
            self::POS_REPORTS => $posRole->isCanViewReports(),
            self::POS_MANAGE => $posRole->getPosRole() === 'supervisor',
            self::POS_SETTINGS => false, // Jamais pour un employé
            default => false,
        };
    }
    
    return false;
}
```

---

## 3. Permissions Granulaires (PosEmployeeRole)

### Rôles POS Prédéfinis

| Rôle | Permissions par défaut |
|------|----------------------|
| `cashier` | Vendre, consulter produits, ouvrir/fermer caisse, créer clients |
| `stock_clerk` | Gérer stock, inventaire, consulter produits |
| `seller` | Vendre, consulter stock, créer clients |
| `supervisor` | Toutes permissions employé |

### Permissions Individuelles

Chaque permission est un booléen indépendant, overridable par le manager :

```php
class PosEmployeeRole
{
    private bool $canApplyDiscount = false;
    private bool $canVoidSale = false;
    private bool $canProcessRefund = false;
    private bool $canManageStock = false;
    private bool $canViewReports = false;
    private bool $canManageCustomers = false;
    private bool $canOpenRegister = true;
    private ?float $maxDiscountPercent = null;
}
```

### Vérification dans les Services

```php
// Dans SaleService
public function applySaleDiscount(Sale $sale, float $percent, User $user): void
{
    $company = $sale->getCompany();
    $posRole = $this->posEmployeeService->getEmployeeRole($user, $company);
    
    // Manager peut toujours
    if (in_array('ROLE_MANAGER', $user->getRoles())) {
        $maxDiscount = $this->posSettings->getMaxDiscountPercent($company);
    } else {
        // Vérifier permission employé
        if (!$posRole || !$posRole->isCanApplyDiscount()) {
            throw new AccessDeniedException('Vous n\'êtes pas autorisé à appliquer des remises.');
        }
        
        // Plafonner la remise
        $maxDiscount = min(
            $posRole->getMaxDiscountPercent() ?? 100,
            $this->posSettings->getMaxDiscountPercent($company)
        );
    }
    
    if ($percent > $maxDiscount) {
        throw new \InvalidArgumentException(
            sprintf('Remise maximum autorisée : %s%%', $maxDiscount)
        );
    }
    
    // Appliquer la remise...
}
```

---

## 4. PIN Caissier

### Hashage

```php
// Dans PosEmployeeService
public function setPin(PosEmployeeRole $role, string $plainPin): void
{
    // Validation : 4-6 chiffres
    if (!preg_match('/^\d{4,6}$/', $plainPin)) {
        throw new \InvalidArgumentException('Le PIN doit contenir 4 à 6 chiffres.');
    }
    
    $role->setPinCode(password_hash($plainPin, PASSWORD_BCRYPT));
}

public function verifyPin(User $user, Company $company, string $plainPin): bool
{
    $role = $this->repository->findOneBy([
        'user' => $user, 
        'company' => $company, 
        'isActive' => true
    ]);
    
    if (!$role || !$role->getPinCode()) {
        return false;
    }
    
    return password_verify($plainPin, $role->getPinCode());
}
```

### Quand demander le PIN

| Action | PIN requis si `require_employee_pin = true` |
|--------|---------------------------------------------|
| Ouverture de caisse | Oui |
| Fermeture de caisse | Oui |
| Annulation de vente | Oui |
| Remboursement | Oui |
| Remise > 20% | Oui |
| Sortie de caisse (cash out) | Oui |
| Ajustement de stock | Non (manager only) |

---

## 5. Audit Trail POS

### Actions Auditées

| Action | Entity Type | Données Capturées |
|--------|------------|-------------------|
| `sale_created` | sale | Montant, nb articles, vendeur, client |
| `sale_voided` | sale | Montant, motif, annulé par |
| `refund_created` | refund | Montant, motif, articles |
| `refund_approved` | refund | Approuvé par |
| `product_created` | product | Nom, SKU, prix |
| `product_updated` | product | Champs modifiés (old → new) |
| `product_deleted` | product | Nom, SKU |
| `price_changed` | product | Ancien prix → nouveau prix |
| `stock_adjusted` | product | Ancien stock → nouveau stock, motif |
| `stock_received` | product | Quantité, fournisseur |
| `register_opened` | register | Fond de caisse, caissier |
| `register_closed` | register | Montant attendu/réel, écart |
| `cash_movement` | register | Type (in/out), montant, motif |
| `inventory_created` | inventory | Type, nb produits |
| `inventory_approved` | inventory | Nb écarts, valeur écart |
| `customer_created` | customer | Nom, téléphone |
| `credit_granted` | customer | Montant, vente associée |
| `credit_paid` | customer | Montant payé |
| `settings_changed` | settings | Paramètres modifiés (old → new) |
| `employee_role_assigned` | employee | Rôle, permissions |
| `employee_role_updated` | employee | Permissions modifiées |
| `discount_applied` | sale | %, montant, par qui |
| `purchase_order_created` | purchase_order | Fournisseur, montant |
| `purchase_order_received` | purchase_order | Quantités reçues |

### Rétention des Logs

- `PosAuditLog` : conservation illimitée (append-only)
- Archivage possible après 2 ans (commande CLI future)
- Export CSV/PDF pour conformité

### Consultation

- **Manager** : voit les logs de sa company uniquement
- **Admin** : voit les logs de toutes les companies
- **Filtres** : par date, action, utilisateur, entité

---

## 6. Protection contre la Fraude

### Ventes
- Toute modification de prix est loguée avec ancien/nouveau
- Les annulations nécessitent un motif
- Les remboursements > X% du montant nécessitent approbation manager
- Les ventes à crédit sont plafonnées par client

### Stock
- Tout mouvement de stock est tracé (qui, quand, combien, pourquoi)
- Les ajustements d'inventaire nécessitent validation manager
- Les écarts d'inventaire sont signalés

### Caisse
- Ouverture/fermeture avec montants tracés
- Écart fond de caisse signalé
- Mouvements de caisse (entrées/sorties) tracés avec motif
- Pas de suppression de données possible (append-only)

### Employés
- PIN individuel hashé
- Déconnexion automatique après inactivité
- Historique complet des actions par employé
- Permissions granulaires pour limiter les abus

---

## 7. Isolation Média (résumé)

Voir `POS_MEDIA_ISOLATION.md` pour le détail complet.

**Points clés :**
- Stockage physique séparé : `var/media/companies/{uuid}/pos/`
- Vérification de chemin avant tout accès
- Quotas par company
- Nouveaux types Media : `pos_product`, `pos_category`, `pos_receipt`, `pos_report`
- Path traversal impossible (vérification `realpath`)

---

## 8. Protection des Endpoints API

```php
#[Route('/api/pos')]
class PosProductApiController extends AbstractController
{
    #[Route('/products/search', methods: ['GET'])]
    public function search(Request $request): JsonResponse
    {
        $user = $this->getUser();
        $company = $user->getPrimaryCompany();
        
        if (!$company) {
            return $this->json(['error' => 'No company'], 403);
        }
        
        // Voter check
        $this->denyAccessUnlessGranted('POS_ACCESS', $company);
        
        // Requête scopée
        $products = $this->productRepository->searchByCompany(
            $company, 
            $request->query->get('q', '')
        );
        
        return $this->json(['products' => $products]);
    }
}
```

**Règles API :**
- JWT obligatoire (firewall `api`)
- Company extraite du token JWT (user → primaryCompany)
- Voter POS vérifié sur chaque endpoint
- Rate limiting sur les endpoints de recherche
- Logging de toutes les requêtes API POS
