Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions backend/app/Console/Commands/AssignSuperAdminCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

namespace HiEvents\Console\Commands;

use Exception;
use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface;
use HiEvents\Repository\Interfaces\UserRepositoryInterface;
use Illuminate\Console\Command;
use Psr\Log\LoggerInterface;

class AssignSuperAdminCommand extends Command
{
protected $signature = 'user:make-superadmin {userId : The ID of the user to make a superadmin}';

protected $description = 'Assign SUPERADMIN role to a user. WARNING: This grants complete system access.';

public function __construct(
private readonly UserRepositoryInterface $userRepository,
private readonly AccountUserRepositoryInterface $accountUserRepository,
private readonly LoggerInterface $logger,
)
{
parent::__construct();
}

public function handle(): int
{
$userId = $this->argument('userId');

$this->warn('⚠️ WARNING: This command will grant COMPLETE SYSTEM ACCESS to the user.');
$this->warn('⚠️ SUPERADMIN users have unrestricted access to all accounts and data.');
$this->newLine();

if (!$this->confirm('Are you sure you want to proceed?', false)) {
$this->info('Operation cancelled.');
return self::FAILURE;
}

try {
$user = $this->userRepository->findById((int)$userId);
} catch (Exception $exception) {
$this->error("Error finding user with ID: $userId" . " Message: " . $exception->getMessage());
return self::FAILURE;
}

$this->info("Found user: {$user->getFullName()} ({$user->getEmail()})");
$this->newLine();

if (!$this->confirm('Confirm assigning SUPERADMIN role to this user?', false)) {
$this->info('Operation cancelled.');
return self::FAILURE;
}

$accountUsers = $this->accountUserRepository->findWhere([
'user_id' => $userId,
]);

if ($accountUsers->isEmpty()) {
$this->error('User is not associated with any accounts.');
return self::FAILURE;
}

$updatedCount = 0;
foreach ($accountUsers as $accountUser) {
if ($accountUser->getRole() === Role::SUPERADMIN->name) {
$this->comment("User already has SUPERADMIN role for account ID: {$accountUser->getAccountId()}");
continue;
}

$this->accountUserRepository->updateWhere(
attributes: [
'role' => Role::SUPERADMIN->name,
],
where: [
'id' => $accountUser->getId(),
]
);

$updatedCount++;

$this->logger->critical('SUPERADMIN role assigned via console command', [
'user_id' => $userId,
'user_email' => $user->getEmail(),
'account_id' => $accountUser->getAccountId(),
'previous_role' => $accountUser->getRole(),
'command' => $this->signature,
]);
}

$this->newLine();
$this->info("✓ Successfully assigned SUPERADMIN role to user across $updatedCount account(s).");
$this->warn("⚠️ User {$user->getFullName()} now has COMPLETE SYSTEM ACCESS.");

$this->logger->critical('SUPERADMIN role assignment completed', [
'user_id' => $userId,
'accounts_updated' => $updatedCount,
]);

return self::SUCCESS;
}
}
9 changes: 9 additions & 0 deletions backend/app/DomainObjects/Enums/Role.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ enum Role: string
{
use BaseEnum;

case SUPERADMIN = 'SUPERADMIN';
case ADMIN = 'ADMIN';
case ORGANIZER = 'ORGANIZER';

public static function getAssignableRoles(): array
{
return [
self::ADMIN->value,
self::ORGANIZER->value,
];
}
}
37 changes: 37 additions & 0 deletions backend/app/Http/Actions/Admin/Accounts/GetAllAccountsAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Admin\Accounts;

use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Resources\Account\AdminAccountResource;
use HiEvents\Services\Application\Handlers\Admin\DTO\GetAllAccountsDTO;
use HiEvents\Services\Application\Handlers\Admin\GetAllAccountsHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class GetAllAccountsAction extends BaseAction
{
public function __construct(
private readonly GetAllAccountsHandler $handler,
)
{
}

public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);

$accounts = $this->handler->handle(new GetAllAccountsDTO(
perPage: min((int)$request->query('per_page', 20), 100),
search: $request->query('search'),
));

return $this->resourceResponse(
resource: AdminAccountResource::class,
data: $accounts
);
}
}
28 changes: 28 additions & 0 deletions backend/app/Http/Actions/Admin/Stats/GetAdminStatsAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Admin\Stats;

use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Services\Application\Handlers\Admin\GetAdminStatsHandler;
use Illuminate\Http\JsonResponse;

class GetAdminStatsAction extends BaseAction
{
public function __construct(
private readonly GetAdminStatsHandler $handler,
)
{
}

public function __invoke(): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);

$stats = $this->handler->handle();

return $this->jsonResponse($stats->toArray());
}
}
37 changes: 37 additions & 0 deletions backend/app/Http/Actions/Admin/Users/GetAllUsersAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Admin\Users;

use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Resources\User\AdminUserResource;
use HiEvents\Services\Application\Handlers\Admin\DTO\GetAllUsersDTO;
use HiEvents\Services\Application\Handlers\Admin\GetAllUsersHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class GetAllUsersAction extends BaseAction
{
public function __construct(
private readonly GetAllUsersHandler $handler,
)
{
}

public function __invoke(Request $request): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);

$users = $this->handler->handle(new GetAllUsersDTO(
perPage: min((int)$request->query('per_page', 20), 100),
search: $request->query('search'),
));

return $this->resourceResponse(
resource: AdminUserResource::class,
data: $users
);
}
}
44 changes: 44 additions & 0 deletions backend/app/Http/Actions/Admin/Users/StartImpersonationAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Admin\Users;

use HiEvents\DomainObjects\Enums\Role;
use HiEvents\Http\Actions\Auth\BaseAuthAction;
use HiEvents\Services\Application\Handlers\Admin\DTO\StartImpersonationDTO;
use HiEvents\Services\Application\Handlers\Admin\StartImpersonationHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StartImpersonationAction extends BaseAuthAction
{
public function __construct(
private readonly StartImpersonationHandler $handler,
)
{
}

public function __invoke(Request $request, int $userId): JsonResponse
{
$this->minimumAllowedRole(Role::SUPERADMIN);

$this->validate($request, [
'account_id' => 'required|exists:accounts,id'
]);

$token = $this->handler->handle(new StartImpersonationDTO(
userId: $userId,
accountId: $request->input('account_id'),
impersonatorId: $this->getAuthenticatedUser()->getId(),
));

$response = $this->jsonResponse([
'message' => __('Impersonation started'),
'redirect_url' => '/manage/events',
'token' => $token
]);

return $this->addTokenToResponse($response, $token);
}
}
44 changes: 44 additions & 0 deletions backend/app/Http/Actions/Admin/Users/StopImpersonationAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Admin\Users;

use HiEvents\Http\Actions\Auth\BaseAuthAction;
use HiEvents\Services\Application\Handlers\Admin\DTO\StopImpersonationDTO;
use HiEvents\Services\Application\Handlers\Admin\StopImpersonationHandler;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;

class StopImpersonationAction extends BaseAuthAction
{
public function __construct(
private readonly StopImpersonationHandler $handler,
private readonly AuthManager $authManager,
)
{
}

public function __invoke(): JsonResponse
{
$isImpersonating = $this->authManager->payload()->get('is_impersonating');

if (!$isImpersonating) {
return $this->errorResponse(__('Not currently impersonating'));
}

$impersonatorId = $this->authManager->payload()->get('impersonator_id');

$token = $this->handler->handle(new StopImpersonationDTO(
impersonatorId: $impersonatorId,
));

$response = $this->jsonResponse([
'message' => __('Impersonation ended'),
'redirect_url' => '/admin/users',
'token' => $token
]);

return $this->addTokenToResponse($response, $token);
}
}
2 changes: 2 additions & 0 deletions backend/app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use HiEvents\Http\Middleware\Authenticate;
use HiEvents\Http\Middleware\EncryptCookies;
use HiEvents\Http\Middleware\HandleDeprecatedTimezones;
use HiEvents\Http\Middleware\LogImpersonationMiddleware;
use HiEvents\Http\Middleware\PreventRequestsDuringMaintenance;
use HiEvents\Http\Middleware\RedirectIfAuthenticated;
use HiEvents\Http\Middleware\SetAccountContext;
Expand Down Expand Up @@ -71,6 +72,7 @@ class Kernel extends HttpKernel
SubstituteBindings::class,
SetAccountContext::class,
SetUserLocaleMiddleware::class,
LogImpersonationMiddleware::class,
],
];

Expand Down
49 changes: 49 additions & 0 deletions backend/app/Http/Middleware/LogImpersonationMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

namespace HiEvents\Http\Middleware;

use Closure;
use Exception;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\Request;
use Psr\Log\LoggerInterface;

class LogImpersonationMiddleware
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly AuthManager $authManager,
)
{
}

public function handle(Request $request, Closure $next)
{
$mutateMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];

$isImpersonating = false;
try {
$isImpersonating = (bool)$this->authManager->payload()->get('is_impersonating', false);
} catch (Exception) {
// Not authenticated or no JWT token
}

if ($this->authManager->check()
&& $isImpersonating
&& in_array($request->method(), $mutateMethods, true)
) {
$this->logger->info('Impersonation action by user ID ' . $this->authManager->payload()->get('impersonator_id'), [
'impersonator_id' => $this->authManager->payload()->get('impersonator_id'),
'impersonated_user_id' => $this->authManager->user()->id,
'account_id' => $this->authManager->payload()->get('account_id'),
'method' => $request->method(),
'url' => $request->fullUrl(),
'ip' => $request->ip(),
'payload' => $request->except(['password', 'token', 'password_confirmation', 'image']),
'timestamp' => now()->toIso8601String(),
]);
}

return $next($request);
}
}
2 changes: 1 addition & 1 deletion backend/app/Http/Request/User/CreateUserRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public function rules(): array
return [
'first_name' => 'required|min:1',
'last_name' => 'min:1|nullable',
'role' => Rule::in(Role::valuesArray()),
'role' => ['required', Rule::in(Role::getAssignableRoles())],
'email' => [
'required',
'email',
Expand Down
2 changes: 1 addition & 1 deletion backend/app/Http/Request/User/UpdateUserRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public function rules(): array
'first_name' => RulesHelper::STRING,
'last_name' => RulesHelper::STRING,
'status' => Rule::in([UserStatus::INACTIVE->name, UserStatus::ACTIVE->name]), // don't allow INVITED
'role' => Rule::in(Role::valuesArray())
'role' => Rule::in(Role::getAssignableRoles())
];
}
}
Loading
Loading