refactor for monolog user create

This commit is contained in:
Charles 2025-12-03 07:58:21 +01:00
parent cb34a18948
commit 47724734a2
6 changed files with 547 additions and 104 deletions

View File

@ -14,23 +14,85 @@ monolog:
when@dev:
monolog:
handlers:
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
critical_errors:
type: fingers_crossed
action_level: critical
handler: error_nested
buffer_size: 50
error_nested:
type: rotating_file
path: "%kernel.logs_dir%/error.log"
level: debug
channels: ["!event"]
# uncomment to get logging in your browser
# you may have to allow bigger header sizes in your Web server configuration
#firephp:
# type: firephp
# level: info
#chromephp:
# type: chromephp
# level: info
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
max_files: 30
error:
type: rotating_file
path: "%kernel.logs_dir%/error.log"
level: error # logs error, critical, alert, emergency
max_files: 30
channels: [ error ]
php_errors:
type: rotating_file
path: "%kernel.logs_dir%/php_error.log"
level: warning # warnings, errors, fatals…
max_files: 30
channels: [ php ]
# User Management
user_management:
type: rotating_file
path: "%kernel.logs_dir%/user_management.log"
level: debug
channels: [ user_management ]
max_files: 30
# Authentication
authentication:
type: rotating_file
path: "%kernel.logs_dir%/authentication.log"
level: debug
channels: [ authentication ]
max_files: 30
# Organization Management
organization_management:
type: rotating_file
path: "%kernel.logs_dir%/organization_management.log"
level: debug
channels: [ organization_management ]
max_files: 30
# Access Control
access_control:
type: rotating_file
path: "%kernel.logs_dir%/access_control.log"
level: debug
channels: [ access_control ]
max_files: 30
# Email Notifications
email_notifications:
type: rotating_file
path: "%kernel.logs_dir%/email_notifications.log"
level: debug
channels: [ email_notifications ]
max_files: 30
# Admin Actions
admin_actions:
type: rotating_file
path: "%kernel.logs_dir%/admin_actions.log"
level: debug
channels: [ admin_actions ]
max_files: 30
# Security
security:
type: rotating_file
path: "%kernel.logs_dir%/security.log"
level: debug
channels: [ security ]
max_files: 30
when@test:
monolog:
@ -57,7 +119,7 @@ when@prod:
error_nested:
type: rotating_file
path: "%kernel.logs_dir%/error.log"
path: "%kernel.logs_dir%/critical.log"
level: debug
max_files: 30

View File

@ -16,6 +16,7 @@ use App\Repository\UsersOrganizationsRepository;
use App\Service\ActionService;
use App\Service\AwsService;
use App\Service\EmailService;
use App\Service\LoggerService;
use App\Service\OrganizationsService;
use App\Service\UserOrganizationAppService;
use App\Service\UserOrganizationService;
@ -51,11 +52,8 @@ class UserController extends AbstractController
private readonly OrganizationsRepository $organizationRepository,
private readonly LoggerInterface $userManagementLogger,
private readonly LoggerInterface $organizationManagementLogger,
private readonly LoggerInterface $accessControlLogger,
private readonly LoggerInterface $EmailNotificationLogger,
private readonly LoggerInterface $adminActionsLogger,
private readonly LoggerInterface $errorLogger,
private readonly LoggerInterface $SecurityLogger,
private readonly LoggerService $loggerService,
private readonly EmailService $emailService,
private readonly AwsService $awsService,
private readonly OrganizationsService $organizationsService,
@ -251,72 +249,130 @@ class UserController extends AbstractController
public function new(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
try {
$actingUser = $this->userService->getUserByIdentifier($this->getUser()->getUserIdentifier());
if ($this->userService->hasAccessTo($actingUser)) {
if (!$this->userService->hasAccessTo($actingUser)) {
throw $this->createAccessDeniedException(self::ACCESS_DENIED);
}
$user = new User();
$form = $this->createForm(UserForm::class, $user);
$form->handleRequest($request);
$orgId = $request->get('organizationId');
if ($orgId){
$org = $this->organizationRepository->find($orgId) ?? throw new NotFoundHttpException(sprintf('%s not found', $orgId));
$org = $orgId ? $this->organizationRepository->find($orgId) : null;
if (!$org && $orgId) {
$this->loggerService->logCritical('Organization not found for user creation', [
'organization_id' => $orgId,
'acting_user_id' => $actingUser->getId(),
'ip' => $request->getClientIp(),
]);
throw $this->createNotFoundException('Organization not found');
}
if ($form->isSubmitted() && $form->isValid()) {
$existingUser = $this->userRepository->findOneBy(['email' => $user->getEmail()]);
if ($existingUser && $orgId) {
$this->userService->handleExistingUser($existingUser, $org);
$this->actionService->createAction("Create new user", $existingUser, $org, "Added user to organization" . $existingUser->getUserIdentifier() . " for organization " . $org->getName());
$this->logger->notice("User added to organization " . $org->getName());
$this->emailService->sendExistingUserNotificationEmail($existingUser, $org);
$this->logger->notice("Existing user notification email sent to " . $existingUser->getUserIdentifier());
$data = ['user' => $existingUser, 'organization' => $org];
$this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED');
// Case : User exists + has organization context
if ($existingUser && $org) {
$this->userService->addExistingUserToOrganization(
$existingUser,
$org,
$actingUser,
$request->getClientIp()
);
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$existingUser->getId(),
$org->getId(),
$actingUser->getId(),
$request->getClientIp(),
"Super Admin linked user to organization",
);
}
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
}
// Case : User exists but NO organization context → error
if ($existingUser) {
$this->loggerService->logError('Attempt to create user with existing email without organization', [
'target_user_email' => $user->getid(),
'acting_user_id' => $actingUser->getId(),
'ip' => $request->getClientIp(),
]);
// Handle file upload
$picture = $form->get('pictureUrl')->getData();
$this->userService->formatNewUserData($user, $picture, true);
$form->get('email')->addError(
new \Symfony\Component\Form\FormError(
'This email is already in use. Add the user to an organization instead.'
)
);
if ($orgId) {
$uo = new UsersOrganizations();
$uo->setUsers($user);
$uo->setOrganization($org);
$uo->setStatut("INVITED");
$uo->setIsActive(false);
$uo->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($uo);
$this->actionService->createAction("Create new user", $user, $org, "Added user to organization" . $user->getUserIdentifier() . " for organization " . $org->getName());
$this->logger->notice("User added to organization " . $org->getName());
$this->emailService->sendPasswordSetupEmail($user, $orgId);
$this->logger->notice("Password setup email sent to " . $user->getUserIdentifier());
$data = ['user' => $uo->getUsers(), 'organization' => $uo->getOrganization()];
$this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED');
}
$this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier());
$this->logger->notice("User created " . $user->getUserIdentifier());
$this->entityManager->persist($user);
$this->entityManager->flush();
if ($orgId) {
return $this->redirectToRoute('organization_show', ['organizationId' => $orgId]);
}
return $this->redirectToRoute('user_index');
}
}
return $this->render('user/new.html.twig', [
'user' => $user,
'form' => $form->createView(),
'organizationId' => $orgId
'organizationId' => $orgId,
]);
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
if ($orgId) {
}
$picture = $form->get('pictureUrl')->getData();
$this->userService->createNewUser($user, $actingUser, $picture, $request->getClientIp());
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
null,
$actingUser->getId(),
$request->getClientIp(),
"Super Admin created new user",
);
}
// Link to organization if provided
if ($org) {
$this->userService->linkUserToOrganization(
$user,
$org,
$actingUser,
$request->getClientIp()
);
if ($this->isGranted('ROLE_SUPER_ADMIN')) {
$this->loggerService->logSuperAdmin(
$user->getId(),
$org->getId(),
$actingUser->getId(),
$request->getClientIp(),
"Super Admin linked user to organization",
);
}
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
}
return $this->redirectToRoute('user_index');
}
return $this->render('user/new.html.twig', [
'user' => $user,
'form' => $form->createView(),
'organizationId' => $orgId,
]);
} catch (\Exception $e) {
$this->loggerService->logError("Error on user creation route: " . $e->getMessage(), [
'ip' => $request->getClientIp(),
]);
if ($orgId ?? null) {
return $this->redirectToRoute('organization_show', ['id' => $orgId]);
}
return $this->redirectToRoute('user_index');
}
}
@ -730,7 +786,8 @@ class UserController extends AbstractController
$uo->setModifiedAt(new \DateTimeImmutable());
try {
$data = ['user' => $uo->getUsers(), 'organization' => $uo->getOrganization()];
$this->emailService->sendPasswordSetupEmail($user, $orgId);
$token = $this->userService->generatePasswordToken($user, $org->getId());
$this->emailService->sendPasswordSetupEmail($user, $token);
$this->logger->info("Invitation email resent to user " . $user->getUserIdentifier() . " for organization " . $org->getName());
$this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED');
return $this->json(['message' => 'Invitation envoyée avec success.'], Response::HTTP_OK);
@ -759,6 +816,7 @@ class UserController extends AbstractController
throw $this->createNotFoundException('Invalid or expired invitation token.');
}
$orgId = $this->userService->getOrgFromToken($token);
if ($orgId) {
$uo = $this->uoRepository->findOneBy(['users' => $user, 'organization' => $orgId]);
if (!$uo || $uo->getStatut() !== 'INVITED') {
$this->logger->warning("User " . $user->getUserIdentifier() . " tried to accept an invitation but no pending invitation was found for organization ID " . $orgId);
@ -770,8 +828,8 @@ class UserController extends AbstractController
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->logger->info("User " . $user->getUserIdentifier() . " accepted invitation for organization ID " . $orgId);
return $this->render('user/show.html.twig', ['user' => $user, 'orgId' => $orgId]);
}
return $this->render('security/login.html.twig');
}
}

View File

@ -14,15 +14,12 @@ class EmailService
{
public function __construct(
private readonly MailerInterface $mailer,
private readonly UserService $userService,
private readonly LoggerInterface $logger,
private UrlGeneratorInterface $urlGenerator
) {}
public function sendPasswordSetupEmail(User $user, int $orgId): void
public function sendPasswordSetupEmail(User $user, string $token): void
{
$token = $this->userService->generatePasswordToken($user, $orgId);
// Generate absolute URL for the password setup route
$link = $this->urlGenerator->generate(
'password_setup',
@ -52,9 +49,8 @@ class EmailService
}
}
public function sendExistingUserNotificationEmail(User $existingUser, Organizations $org): void
public function sendExistingUserNotificationEmail(User $existingUser, Organizations $org, $token): void
{
$token = $this->userService->generatePasswordToken($existingUser, $org->getId());
$link = $this->urlGenerator->generate('user_accept',[
'id' => $existingUser->getId(),
'token' => $token

View File

@ -0,0 +1,142 @@
<?php
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
readonly class LoggerService
{
public function __construct(
private LoggerInterface $userManagementLogger,
private LoggerInterface $organizationManagementLogger,
private LoggerInterface $accessControlLogger,
private LoggerInterface $emailNotificationLogger,
private LoggerInterface $adminActionsLogger,
private LoggerInterface $securityLogger,
private LoggerInterface $errorLogger,
) {}
// User Management Logs
public function logUserCreated(int $userId, int $actingUserId, ?string $ip): void
{
$this->userManagementLogger->notice("New user created: $userId", [
'target_user_id' => $userId,
'acting_user_id' => $actingUserId,
'ip' => $ip,
'timestamp' => $this->now(),
]);
}
public function logUserEdited(int $userId, int $actingUserId, ?int $orgId, ?string $ip): void
{
$this->userManagementLogger->notice('User information edited', [
'target_user_id' => $userId,
'acting_user_id' => $actingUserId,
'organization_id' => $orgId,
'ip' => $ip,
'timestamp' => $this->now(),
]);
}
// Organization Management Logs
public function logUserOrganizationLinkCreated(int $userId, int $orgId, int $actingUserId, ?int $uoId, ?string $ip): void
{
$this->organizationManagementLogger->notice('User-Organization link created', [
'target_user_id' => $userId,
'organization_id' => $orgId,
'acting_user_id' => $actingUserId,
'uo_id' => $uoId,
'ip' => $ip,
'timestamp' => $this->now(),
]);
}
public function logExistingUserAddedToOrg(int $userId, int $orgId, int $actingUserId, int $uoId, ?string $ip): void
{
$this->organizationManagementLogger->notice('Existing user added to organization', [
'target_user_id' => $userId,
'organization_id' => $orgId,
'acting_user_id' => $actingUserId,
'uo_id' => $uoId,
'ip' => $ip,
'timestamp' => $this->now(),
]);
}
// Email Notification Logs
public function logPasswordSetupEmailSent(int $userId, ?int $orgId, ?string $ip): void
{
$this->emailNotificationLogger->notice("Password setup email sent to $userId", [
'target_user_id' => $userId,
'organization_id' => $orgId,
'ip' => $ip,
'timestamp' => $this->now(),
]);
}
public function logExistingUserNotificationSent(int $userId, int $orgId, ?string $ip): void
{
$this->emailNotificationLogger->notice("Existing user notification email sent to $userId", [
'target_user_id' => $userId,
'organization_id' => $orgId,
'ip' => $ip,
'timestamp' => $this->now(),
]);
}
public function logAdminNotified(int $adminUserId, int $targetUserId, int $orgId, int $actingUserId, ?string $ip): void
{
$this->emailNotificationLogger->notice('Organization admin notified', [
'admin_user_id' => $adminUserId,
'target_user_id' => $targetUserId,
'organization_id' => $orgId,
'acting_user_id' => $actingUserId,
'ip' => $ip,
'timestamp' => $this->now(),
]);
}
public function logSuperAdmin(int $userId, ?int $orgId, int $actingUserId, ?string $ip, string $message): void
{
$this->adminActionsLogger->notice($message, [
'target_user_id' => $userId,
'organization_id' => $orgId,
'acting_user_id' => $actingUserId,
'ip' => $ip,
'timestamp' => $this->now(),
]);
}
// Error Logs
public function logError(string $message, array $context = []): void
{
$this->errorLogger->error($message, array_merge($context, [
'timestamp' => $this->now(),
]));
}
public function logCritical(string $message, array $context = []): void
{
$this->errorLogger->critical($message, array_merge($context, [
'timestamp' => $this->now(),
]));
}
// Security Logs
public function logAccessDenied(int $targetId, ?int $actingUserId, ?string $ip): void
{
$this->securityLogger->warning('Access denied', [
'target_id' => $targetId,
'acting_user_id' => $actingUserId,
'ip' => $ip,
'timestamp' => $this->now(),
]);
}
// Helper
private function now(): string
{
return (new \DateTimeImmutable('now'))->format(DATE_ATOM);
}
}

View File

@ -6,6 +6,7 @@ use App\Entity\Apps;
use App\Entity\Organizations;
use App\Entity\Roles;
use App\Entity\UserOrganizatonApp;
use App\Entity\UsersOrganizations;
use App\Repository\UsersOrganizationsRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
@ -62,7 +63,7 @@ class OrganizationsService
}
public function notifyOrganizationAdmins(array $data, string $type): void
public function notifyOrganizationAdmins(array $data, string $type): UsersOrganizations
{
$roleAdmin = $this->entityManager->getRepository(Roles::class)->findOneBy(['name' => 'ADMIN']);
@ -130,6 +131,8 @@ class OrganizationsService
}
}
return $adminUO;
}
}

View File

@ -27,7 +27,14 @@ class UserService
public function __construct(private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
private readonly AwsService $awsService
private readonly AwsService $awsService,
private readonly LoggerService $loggerService,
private readonly ActionService $actionService,
private readonly EmailService $emailService,
private readonly OrganizationsService $organizationsService,
)
{
@ -330,7 +337,7 @@ class UserService
return $formatted;
}
public function generatePasswordToken(User $user, int $orgId): string
public function generatePasswordToken(User $user, int $orgId = null): string
{
$orgString = "o" . $orgId . "@";
$token = $orgString . bin2hex(random_bytes(32));
@ -434,7 +441,7 @@ class UserService
* @param Organizations $organization
* @return void
*/
public function handleExistingUser(User $user, Organizations $organization): void
public function handleExistingUser(User $user, Organizations $organization): int
{
if (!$user->isActive()) {
$user->setIsActive(true);
@ -448,6 +455,8 @@ class UserService
$uo->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($uo);
$this->entityManager->flush();
return $uo->getId();
}
/**
@ -479,4 +488,177 @@ class UserService
$this->handleProfilePicture($user, $picture);
}
}
/**
* Handle existing user being added to an organization
*/
public function addExistingUserToOrganization(
User $existingUser,
Organizations $org,
User $actingUser,
?string $ip
): int {
try {
$uoId = $this->handleExistingUser($existingUser, $org);
$this->loggerService->logExistingUserAddedToOrg(
$existingUser->getId(),
$org->getId(),
$actingUser->getId(),
$uoId,
$ip
);
$this->actionService->createAction(
"Add existing user to organization",
$actingUser,
$org,
"Added user {$existingUser->getUserIdentifier()} to {$org->getName()}"
);
$this->sendExistingUserNotifications($existingUser, $org, $actingUser, $ip);
return $uoId;
} catch (\Exception $e) {
$this->loggerService->logError('Error linking existing user to organization: ' . $e->getMessage(), [
'target_user_id' => $existingUser->getId(),
'organization_id' => $org->getId(),
'acting_user_id' => $actingUser->getId(),
'ip' => $ip,
]);
throw $e;
}
}
/**
* Create a brand-new user
*/
public function createNewUser(User $user, User $actingUser, $picture, ?string $ip): void
{
try {
$this->formatNewUserData($user, $picture, true);
$this->entityManager->persist($user);
$this->entityManager->flush();
$this->loggerService->logUserCreated($user->getId(), $actingUser->getId(), $ip);
$token = $this->generatePasswordToken($user);
$this->emailService->sendPasswordSetupEmail($user, $token);
$this->actionService->createAction("Create new user", $actingUser, null, $user->getUserIdentifier());
} catch (\Exception $e) {
$this->loggerService->logError('Error creating new user: ' . $e->getMessage(), [
'target_user_email' => $user->getEmail(),
'acting_user_id' => $actingUser->getId(),
'ip' => $ip,
]);
throw $e;
}
}
/**
* Link newly created user to an organization
*/
public function linkUserToOrganization(
User $user,
Organizations $org,
User $actingUser,
?string $ip
): UsersOrganizations {
try {
$uo = new UsersOrganizations();
$uo->setUsers($user);
$uo->setOrganization($org);
$uo->setStatut("INVITED");
$uo->setIsActive(false);
$uo->setModifiedAt(new \DateTimeImmutable('now'));
$this->entityManager->persist($uo);
$this->entityManager->flush();
$this->loggerService->logUserOrganizationLinkCreated(
$user->getId(),
$org->getId(),
$actingUser->getId(),
$uo->getId(),
$ip
);
$this->actionService->createAction(
"Link user to organization",
$actingUser,
$org,
"Added {$user->getUserIdentifier()} to {$org->getName()}"
);
$this->sendNewUserNotifications($user, $org, $actingUser, $ip);
return $uo;
} catch (\Exception $e) {
$this->loggerService->logError('Error linking user to organization: ' . $e->getMessage(), [
'target_user_id' => $user->getId(),
'organization_id' => $org->getId(),
'acting_user_id' => $actingUser->getId(),
'ip' => $ip,
]);
throw $e;
}
}
// Private helpers for email notifications
private function sendExistingUserNotifications(User $user, Organizations $org, User $actingUser, ?string $ip): void
{
try {
$token = $this->generatePasswordToken($user, $org->getId());
$this->emailService->sendExistingUserNotificationEmail($user, $org, $token);
$this->loggerService->logExistingUserNotificationSent($user->getId(), $org->getId(), $ip);
} catch (\Exception $e) {
$this->loggerService->logError("Error sending existing user notification: " . $e->getMessage(), [
'target_user_id' => $user->getId(),
'organization_id' => $org->getId(),
'ip' => $ip,
]);
}
$this->notifyOrgAdmins($user, $org, $actingUser, $ip);
}
private function sendNewUserNotifications(User $user, Organizations $org, User $actingUser, ?string $ip): void
{
try {
$token = $this->generatePasswordToken($user, $org->getId());
$this->emailService->sendPasswordSetupEmail($user, $token);
$this->loggerService->logPasswordSetupEmailSent($user->getId(), $org->getId(), $ip);
} catch (\Exception $e) {
$this->loggerService->logError("Error sending password setup email: " . $e->getMessage(), [
'target_user_id' => $user->getId(),
'organization_id' => $org->getId(),
'ip' => $ip,
]);
}
$this->notifyOrgAdmins($user, $org, $actingUser, $ip);
}
private function notifyOrgAdmins(User $user, Organizations $org, User $actingUser, ?string $ip): void
{
try {
$data = ['user' => $user, 'organization' => $org];
$adminsUos = $this->organizationsService->notifyOrganizationAdmins($data, 'USER_INVITED');
foreach ($adminsUos as $adminUo) {
$this->loggerService->logAdminNotified(
$adminUo->getUsers()->getId(),
$user->getId(),
$org->getId(),
$actingUser->getId(),
$ip
);
}
} catch (\Exception $e) {
$this->loggerService->logError("Error notifying organization admins: " . $e->getMessage(), [
'target_user_id' => $user->getId(),
'organization_id' => $org->getId(),
'ip' => $ip,
]);
}
}
}