Initial commit device-management
tests / ci (push) Has been cancelled

This commit is contained in:
Wian Drs
2026-08-25 09:42:00 +07:00
commit 1e80be180e
1159 changed files with 149545 additions and 0 deletions
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Casts;
use App\Services\Encryption\TenantEnvelopeEncryption;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use RuntimeException;
class TenantEncrypted implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
$tenantId = (int) ($attributes['tenant_id'] ?? 0);
if ($tenantId < 1) {
throw new RuntimeException('TENANT_CONTEXT_REQUIRED_FOR_DECRYPTION');
}
return app(TenantEnvelopeEncryption::class)->decrypt($tenantId, $value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
$tenantId = (int) ($attributes['tenant_id'] ?? $model->getAttribute('tenant_id'));
if ($tenantId < 1) {
throw new RuntimeException('TENANT_CONTEXT_REQUIRED_FOR_ENCRYPTION');
}
return app(TenantEnvelopeEncryption::class)->encrypt($tenantId, $value);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Concerns;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Password|ValidationRule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, ValidationRule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
#[Signature('app:create-platform-admin {--name=} {--email=}')]
#[Description('Create the first platform-level super administrator')]
class CreatePlatformAdmin extends Command
{
public function handle(): int
{
if (config('deployment.mode') === 'self_hosted') {
$this->error('Platform Super Admin hanya dibuat pada RADIQ control plane atau managed cloud.');
return self::FAILURE;
}
$name = $this->option('name') ?: $this->ask('Full name');
$email = $this->option('email') ?: $this->ask('Email address');
$password = $this->secret('Password (minimum 12 characters)');
$confirmation = $this->secret('Confirm password');
$validator = Validator::make(
compact('name', 'email', 'password') + ['password_confirmation' => $confirmation],
[
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
],
);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return self::FAILURE;
}
$admin = new User;
$admin->forceFill([
'tenant_id' => null,
'name' => $name,
'email' => $email,
'email_verified_at' => now(),
'password' => $password,
'is_platform_admin' => true,
'is_active' => true,
])->save();
$this->info('Platform Master Admin created successfully.');
return self::SUCCESS;
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\TenantProvisioningService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rules\Password;
#[Signature('app:install-tenant {--name=} {--slug=} {--admin-name=} {--admin-email=} {--domain=}')]
#[Description('Provision the only Tenant Admin account for a self-hosted installation')]
class InstallTenant extends Command
{
/**
* Execute the console command.
*/
public function handle(TenantProvisioningService $service): int
{
if (config('deployment.mode') !== 'self_hosted') {
$this->error('Command ini hanya boleh digunakan saat DEPLOYMENT_MODE=self_hosted.');
return self::FAILURE;
}
if (Tenant::exists()) {
$this->error('Instalasi self-hosted ini sudah memiliki tenant.');
return self::FAILURE;
}
$data = [
'name' => $this->option('name') ?: $this->ask('Nama tenant/ISP'),
'slug' => $this->option('slug') ?: $this->ask('Slug tenant'),
'owner_name' => $this->option('admin-name') ?: $this->ask('Nama Tenant Admin'),
'owner_email' => $this->option('admin-email') ?: $this->ask('Email Tenant Admin'),
'owner_password' => $this->secret('Password Tenant Admin (minimum 12 karakter)'),
'deployment_type' => 'self_hosted', 'deployment_domain' => $this->option('domain'),
];
$confirmation = $this->secret('Konfirmasi password Tenant Admin');
$validator = Validator::make($data + ['owner_password_confirmation' => $confirmation], [
'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'alpha_dash', 'max:100'],
'owner_name' => ['required', 'string', 'max:255'], 'owner_email' => ['required', 'email'],
'owner_password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
]);
if ($validator->fails()) {
foreach ($validator->errors()->all() as $error) {
$this->error($error);
}
return self::FAILURE;
}
$service->createWithOwner($data);
$this->info('RADIQ NDM self-hosted berhasil dipasang. Hanya akun Tenant Admin yang dibuat.');
return self::SUCCESS;
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum DeploymentMode: string
{
case ControlPlane = 'control_plane';
case ManagedCloud = 'managed_cloud';
case SelfHosted = 'self_hosted';
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Enums;
enum LicenseStatus: string
{
case Pending = 'pending';
case Active = 'active';
case Grace = 'grace';
case Expired = 'expired';
case Suspended = 'suspended';
case Revoked = 'revoked';
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum SystemRole: string
{
case MasterAdmin = 'MASTER ADMIN';
case TenantAdmin = 'TENANT ADMIN';
case TenantUser = 'TENANT USER';
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Administration;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administration\StoreTenantRequest;
use App\Http\Requests\Administration\UpdateTenantRequest;
use App\Models\Tenant;
use App\Models\User;
use App\Services\TenantProvisioningService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class TenantController extends Controller
{
public function index(): Response
{
$this->authorize('viewAny', Tenant::class);
$tenants = Tenant::query()
->withCount('users')
->with('installations:id,tenant_id,deployment_type,domain,status,last_seen_at')
->orderBy('name')
->paginate(15)
->withQueryString();
return Inertia::render('administration/tenants/index', ['tenants' => $tenants]);
}
public function create(): Response
{
$this->authorize('create', Tenant::class);
return Inertia::render('administration/tenants/create');
}
public function store(StoreTenantRequest $request, TenantProvisioningService $service): RedirectResponse
{
$tenant = $service->createWithOwner($request->validated());
return to_route('administration.tenants.edit', $tenant)
->with('success', 'Tenant dan Tenant Owner berhasil dibuat.');
}
public function edit(Tenant $tenant): Response
{
$this->authorize('update', $tenant);
$users = User::query()
->withoutGlobalScope('tenant')
->where('tenant_id', $tenant->id)
->orderBy('name')
->get(['uuid', 'name', 'email', 'is_active', 'created_at']);
return Inertia::render('administration/tenants/edit', [
'tenant' => $tenant,
'users' => $users,
'installation' => $tenant->installations()->first(['id', 'deployment_type', 'domain', 'status', 'last_seen_at']),
]);
}
public function update(UpdateTenantRequest $request, Tenant $tenant): RedirectResponse
{
$this->authorize('update', $tenant);
$data = $request->validated();
$tenant->update(collect($data)->only(['name', 'slug', 'is_active'])->all());
if (isset($data['deployment_type'])) {
$tenant->installations()->first()?->update([
'deployment_type' => $data['deployment_type'],
'domain' => $data['deployment_domain'] ?? null,
]);
}
return back()->with('success', 'Tenant berhasil diperbarui.');
}
public function destroy(Tenant $tenant): RedirectResponse
{
$this->authorize('delete', $tenant);
abort_if($tenant->users()->exists(), 422, 'Tenant yang masih memiliki user tidak dapat dihapus. Nonaktifkan tenant sebagai gantinya.');
$tenant->delete();
return to_route('administration.tenants.index')->with('success', 'Tenant berhasil dihapus.');
}
}
@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers\Administration;
use App\Enums\SystemRole;
use App\Http\Controllers\Controller;
use App\Http\Requests\Administration\ResetUserPasswordRequest;
use App\Http\Requests\Administration\StoreUserRequest;
use App\Http\Requests\Administration\UpdateUserRequest;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class UserController extends Controller
{
public function index(Tenant $tenant): Response
{
abort_unless(request()->user()->can('user.view'), 403);
$users = User::query()
->with('roles:id,name')
->when(! request()->user()->is_platform_admin, fn ($query) => $query->role(SystemRole::TenantUser->value))
->orderBy('name')
->paginate(15)
->withQueryString();
return Inertia::render('administration/users/index', compact('tenant', 'users'));
}
public function create(Tenant $tenant): Response
{
abort_unless(request()->user()->can('user.create'), 403);
return Inertia::render('administration/users/create', ['tenant' => $tenant]);
}
public function store(StoreUserRequest $request, Tenant $tenant): RedirectResponse
{
$data = $request->validated();
$user = User::create([
'tenant_id' => $tenant->id,
'name' => $data['name'],
'email' => $data['email'],
'password' => $data['password'],
'is_active' => $data['is_active'] ?? true,
]);
$user->forceFill(['email_verified_at' => now()])->save();
$user->assignRole(SystemRole::TenantUser->value);
return to_route('administration.users.index', $tenant)->with('success', 'User berhasil dibuat.');
}
public function edit(Tenant $tenant, User $user): Response
{
$this->ensureManageableUser($tenant, $user);
abort_unless(request()->user()->can('user.update'), 403);
return Inertia::render('administration/users/edit', [
'tenant' => $tenant,
'managedUser' => $user->load('roles:id,name'),
]);
}
public function update(UpdateUserRequest $request, Tenant $tenant, User $user): RedirectResponse
{
$this->ensureManageableUser($tenant, $user);
$data = $request->validated();
$user->update(['name' => $data['name'], 'email' => $data['email']]);
return back()->with('success', 'User berhasil diperbarui.');
}
public function resetPassword(ResetUserPasswordRequest $request, Tenant $tenant, User $user): RedirectResponse
{
$this->ensureManageableUser($tenant, $user);
$user->update(['password' => $request->validated('password')]);
$user->tokens()->delete();
return back()->with('success', 'Password user berhasil direset dan token API dicabut.');
}
public function toggleActive(Tenant $tenant, User $user): RedirectResponse
{
$this->ensureManageableUser($tenant, $user);
abort_unless(request()->user()->can('user.update'), 403);
abort_if(request()->user()->is($user), 422, 'Anda tidak dapat menonaktifkan akun sendiri.');
abort_if($user->is_active && $user->hasRole(SystemRole::TenantAdmin->value), 422, 'Akun Tenant Admin utama tidak dapat dinonaktifkan dari pengelolaan user tenant.');
$user->update(['is_active' => ! $user->is_active]);
$user->tokens()->delete();
return back()->with('success', $user->is_active ? 'User diaktifkan.' : 'User dinonaktifkan.');
}
public function destroy(Tenant $tenant, User $user): RedirectResponse
{
$this->ensureManageableUser($tenant, $user);
abort_unless(request()->user()->can('user.delete'), 403);
abort_if(request()->user()->is($user), 422, 'Anda tidak dapat menghapus akun sendiri.');
abort_if($user->hasRole(SystemRole::TenantAdmin->value), 422, 'Akun Tenant Admin utama tidak dapat dihapus dari pengelolaan user tenant.');
$user->delete();
return to_route('administration.users.index', $tenant)->with('success', 'User berhasil dihapus.');
}
private function ensureManageableUser(Tenant $tenant, User $user): void
{
abort_unless($user->tenant_id === $tenant->id && ! $user->is_platform_admin, 404);
abort_if(
! request()->user()->is_platform_admin && ! $user->hasRole(SystemRole::TenantUser->value),
403,
'Tenant Admin hanya dapat mengelola Tenant User.',
);
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreTenantRequest;
use App\Http\Requests\UpdateTenantRequest;
use App\Http\Resources\TenantResource;
use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class TenantController extends Controller
{
public function index(): AnonymousResourceCollection
{
$this->authorize('viewAny', Tenant::class);
$user = request()->user();
$query = Tenant::query()->orderBy('name');
if (! ($user->is_platform_admin && $user->can('tenant.access-any'))) {
$query->whereKey($user->tenant_id);
}
return TenantResource::collection($query->paginate());
}
public function store(StoreTenantRequest $request): JsonResponse
{
$tenant = Tenant::create($request->validated());
return response()->json(['success' => true, 'message' => 'Tenant created successfully', 'data' => new TenantResource($tenant)], 201);
}
public function show(Tenant $tenant): JsonResponse
{
$this->authorize('view', $tenant);
return response()->json(['success' => true, 'message' => 'Tenant retrieved successfully', 'data' => new TenantResource($tenant)]);
}
public function update(UpdateTenantRequest $request, Tenant $tenant): JsonResponse
{
$this->authorize('update', $tenant);
$tenant->update($request->validated());
return response()->json(['success' => true, 'message' => 'Tenant updated successfully', 'data' => new TenantResource($tenant)]);
}
public function destroy(Tenant $tenant): JsonResponse
{
$this->authorize('delete', $tenant);
$tenant->delete();
return response()->json(['success' => true, 'message' => 'Tenant deleted successfully', 'data' => null]);
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
abstract class Controller
{
use AuthorizesRequests, ValidatesRequests;
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Enums\LicenseStatus;
use App\Enums\SystemRole;
use App\Models\DeploymentInstallation;
use App\Models\Device;
use App\Models\License;
use App\Models\Tenant;
use App\Models\User;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __invoke(): Response
{
$user = request()->user();
if ($user->is_platform_admin) {
return Inertia::render('dashboard', [
'role' => SystemRole::MasterAdmin->value,
'tenant' => null,
'stats' => [
'tenants' => Tenant::count(),
'activeTenants' => Tenant::where('is_active', true)->count(),
'users' => User::withoutGlobalScope('tenant')->where('is_platform_admin', false)->count(),
'activeLicenses' => License::withoutGlobalScope('tenant')->where('status', LicenseStatus::Active->value)->count(),
'installations' => DeploymentInstallation::withoutGlobalScope('tenant')->count(),
],
]);
}
$role = $user->hasRole(SystemRole::TenantAdmin->value)
? SystemRole::TenantAdmin->value
: SystemRole::TenantUser->value;
return Inertia::render('dashboard', [
'role' => $role,
'tenant' => $user->tenant?->only(['id', 'name', 'slug', 'is_active']),
'stats' => [
'users' => User::count(),
'activeUsers' => User::where('is_active', true)->count(),
'devices' => Device::count(),
'canAddDevice' => $user->can('device.create'),
],
]);
}
}
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreDeviceAccessUserRequest;
use App\Http\Requests\UpdateDeviceAccessUserRequest;
use App\Jobs\SyncDeviceUser;
use App\Models\Device;
use App\Models\DeviceAccessUser;
use App\Models\DeviceUserAssignment;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class DeviceAccessUserController extends Controller
{
public function index(Request $request): Response
{
abort_unless($request->user()->can('device.user.view'), 403);
$users = DeviceAccessUser::query()->with(['assignments' => fn ($query) => $query->with('device:id,name')])
->when($request->integer('device_id'), fn ($query, $deviceId) => $query->whereHas('assignments', fn ($assignment) => $assignment->where('device_id', $deviceId)))
->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where(fn ($nested) => $nested->where('display_name', 'ilike', '%'.$request->string('search').'%')->orWhere('username', 'ilike', '%'.$request->string('search').'%')))
->orderBy('display_name')->paginate(15)->withQueryString();
return Inertia::render('device-users/index', ['deviceUsers' => $users, 'devices' => Device::orderBy('name')->get(['id', 'name']), 'filters' => $request->only('search', 'device_id')]);
}
public function create(): Response
{
abort_unless(request()->user()->can('device.user.create'), 403);
return Inertia::render('device-users/form', ['deviceUser' => null, 'devices' => $this->devices()]);
}
public function store(StoreDeviceAccessUserRequest $request): RedirectResponse
{
$data = $request->validated();
$this->protectManagerGroup($data['group_name']);
$assignments = DB::transaction(function () use ($request, $data) {
$user = DeviceAccessUser::create(['tenant_id' => $request->user()->tenant_id, 'display_name' => $data['display_name'], 'username' => $data['username'], 'password' => $data['password'], 'is_enabled' => $data['is_enabled'] ?? true, 'notes' => $data['notes'] ?? null, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
return collect($data['device_ids'])->map(fn ($deviceId) => DeviceUserAssignment::create(['tenant_id' => $request->user()->tenant_id, 'device_access_user_id' => $user->id, 'device_id' => $deviceId, 'group_name' => $data['group_name'], 'sync_status' => 'queued']));
});
$assignments->each(fn ($assignment) => SyncDeviceUser::dispatch($request->user()->tenant_id, $assignment->id));
return to_route('device-users.index')->with('success', 'User perangkat dibuat dan sinkronisasi dimasukkan ke queue.');
}
public function edit(DeviceAccessUser $deviceAccessUser): Response
{
$this->ensureOwned($deviceAccessUser);
abort_unless(request()->user()->can('device.user.update'), 403);
return Inertia::render('device-users/form', ['deviceUser' => $deviceAccessUser->load('assignments:id,device_access_user_id,device_id,group_name'), 'devices' => $this->devices()]);
}
public function update(UpdateDeviceAccessUserRequest $request, DeviceAccessUser $deviceAccessUser): RedirectResponse
{
$this->ensureOwned($deviceAccessUser);
$data = $request->validated();
$this->protectManagerGroup($data['group_name']);
$assignments = DB::transaction(function () use ($request, $data, $deviceAccessUser) {
$attributes = ['display_name' => $data['display_name'], 'username' => $data['username'], 'is_enabled' => $data['is_enabled'] ?? false, 'notes' => $data['notes'] ?? null, 'updated_by' => $request->user()->id];
if (filled($data['password'] ?? null)) {
$attributes['password'] = $data['password'];
}
$deviceAccessUser->update($attributes);
$selected = collect($data['device_ids'])->map(fn ($id) => (int) $id);
$deviceAccessUser->assignments()->whereNotIn('device_id', $selected)->update(['desired_operation' => 'delete', 'sync_status' => 'queued']);
foreach ($selected as $deviceId) {
$deviceAccessUser->assignments()->updateOrCreate(['device_id' => $deviceId], ['tenant_id' => $request->user()->tenant_id, 'group_name' => $data['group_name'], 'desired_operation' => 'upsert', 'sync_status' => 'queued', 'error_code' => null]);
}
return $deviceAccessUser->assignments()->where('sync_status', 'queued')->get();
});
$assignments->each(fn ($assignment) => SyncDeviceUser::dispatch($request->user()->tenant_id, $assignment->id));
return to_route('device-users.index')->with('success', 'Perubahan disimpan dan sinkronisasi dijadwalkan.');
}
public function destroy(DeviceAccessUser $deviceAccessUser): RedirectResponse
{
$this->ensureOwned($deviceAccessUser);
abort_unless(request()->user()->can('device.user.delete'), 403);
$assignments = DB::transaction(function () use ($deviceAccessUser) {
$deviceAccessUser->assignments()->update(['desired_operation' => 'delete', 'sync_status' => 'queued']);
$items = $deviceAccessUser->assignments()->get();
$deviceAccessUser->delete();
return $items;
});
$assignments->each(fn ($assignment) => SyncDeviceUser::dispatch(request()->user()->tenant_id, $assignment->id));
return back()->with('success', 'Penghapusan user perangkat dimasukkan ke queue.');
}
private function devices()
{
return Device::with('vendor:id,name')->where('is_active', true)->orderBy('name')->get(['id', 'name', 'device_vendor_id', 'status', 'activation_status']);
}
private function ensureOwned(DeviceAccessUser $user): void
{
abort_unless(! request()->user()->is_platform_admin && $user->tenant_id === request()->user()->tenant_id, 404);
}
private function protectManagerGroup(string $group): void
{
abort_if($group === 'RADIQ-MANAGER' && ! request()->user()->can('device.master_account.manage'), 403);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SystemRole;
use App\Models\Device;
use App\Models\TenantDevicePolicy;
use App\Services\Devices\MikrotikActivationService;
use Illuminate\Http\RedirectResponse;
use Throwable;
class DeviceActivationController extends Controller
{
public function __invoke(Device $device, MikrotikActivationService $service): RedirectResponse
{
$user = request()->user();
abort_if($user->is_platform_admin || $device->tenant_id !== $user->tenant_id, 404);
abort_unless($user->can('device.connect'), 403);
if ($user->hasRole(SystemRole::TenantUser->value)) {
$policy = TenantDevicePolicy::where('tenant_id', $user->tenant_id)->first();
abort_if(! $policy?->tenant_user_can_update_own || $device->created_by !== $user->id, 403);
}
$device->update(['activation_status' => 'processing', 'activation_message' => null]);
try {
$service->activate($device->load('vendor'));
$device->update(['activation_status' => 'active', 'activated_at' => now(), 'activation_message' => 'Group dan Base User RADIQ berhasil disinkronkan.']);
return back()->with('success', 'Perangkat aktif dan akun management RADIQ berhasil dibuat.');
} catch (Throwable $exception) {
report($exception);
$message = str($exception->getMessage())->before(':')->limit(80)->toString();
$device->update(['activation_status' => 'failed', 'activation_message' => $message]);
return back()->withErrors(['activation' => 'Aktivasi gagal: '.$message]);
}
}
}
@@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SystemRole;
use App\Http\Requests\StoreDeviceRequest;
use App\Http\Requests\UpdateDeviceRequest;
use App\Models\Device;
use App\Models\DeviceModel;
use App\Models\DeviceType;
use App\Models\DeviceVendor;
use App\Models\TenantDevicePolicy;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DeviceController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index(Request $request): Response
{
$this->ensureTenantAccount();
abort_unless($request->user()->can('device.view'), 403);
$devices = Device::query()->with(['vendor:id,name', 'type:id,name', 'model:id,name', 'creator:id,name'])->withCount('userAssignments')
->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where(fn ($nested) => $nested
->where('name', 'ilike', '%'.$request->string('search').'%')
->orWhere('management_address', 'ilike', '%'.$request->string('search').'%')
->orWhere('location', 'ilike', '%'.$request->string('search').'%')))
->when($request->integer('vendor'), fn ($query, $vendor) => $query->where('device_vendor_id', $vendor))
->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')))
->latest()->paginate(15)->withQueryString();
return Inertia::render('devices/index', ['devices' => $devices, 'vendors' => DeviceVendor::where('is_active', true)->get(['id', 'name']), 'filters' => $request->only('search', 'vendor', 'status'), 'policy' => $this->policy(), 'isTenantAdmin' => $request->user()->hasRole(SystemRole::TenantAdmin->value)]);
}
/**
* Show the form for creating a new resource.
*/
public function create(): Response
{
$this->authorizeCreate();
return Inertia::render('devices/form', $this->catalog() + ['device' => null]);
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreDeviceRequest $request): RedirectResponse
{
$this->authorizeCreate();
$this->validateModelCombination($request);
Device::create($request->validated() + ['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
return to_route('devices.index')->with('success', 'Perangkat berhasil ditambahkan.');
}
/**
* Display the specified resource.
*/
public function show(Device $device): Response
{
$this->ensureOwnedDevice($device);
abort_unless(request()->user()->can('device.view'), 403);
return Inertia::render('devices/show', [
'device' => $device->load(['vendor:id,name,slug', 'type:id,name', 'model:id,name', 'creator:id,name']),
'credentials' => request()->user()->can('device.credential.view')
? $device->credentials()->get(['id', 'name', 'username', 'connection_type', 'privilege_type', 'is_master', 'is_active', 'last_verified_at'])
: [],
]);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Device $device): Response
{
$this->ensureOwnedDevice($device);
$this->authorizeUpdate($device);
return Inertia::render('devices/form', $this->catalog() + ['device' => $device]);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateDeviceRequest $request, Device $device): RedirectResponse
{
$this->ensureOwnedDevice($device);
$this->authorizeUpdate($device);
$this->validateModelCombination($request);
$device->update($request->validated() + ['updated_by' => $request->user()->id]);
return to_route('devices.show', $device)->with('success', 'Perangkat berhasil diperbarui.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Device $device): RedirectResponse
{
$this->ensureOwnedDevice($device);
abort_unless(request()->user()->can('device.delete'), 403);
abort_if(request()->user()->hasRole(SystemRole::TenantUser->value) && (! $this->policy()->tenant_user_can_delete_own || $device->created_by !== request()->user()->id), 403);
$device->delete();
return to_route('devices.index')->with('success', 'Perangkat berhasil dihapus.');
}
private function catalog(): array
{
return ['vendors' => DeviceVendor::where('is_active', true)->get(['id', 'name']), 'types' => DeviceType::where('is_active', true)->get(['id', 'name']), 'models' => DeviceModel::where('is_active', true)->get(['id', 'name', 'device_vendor_id', 'device_type_id'])];
}
private function policy(): TenantDevicePolicy
{
return TenantDevicePolicy::firstOrCreate(['tenant_id' => request()->user()->tenant_id]);
}
private function ensureTenantAccount(): void
{
abort_if(request()->user()->is_platform_admin || ! request()->user()->tenant_id, 403);
}
private function ensureOwnedDevice(Device $device): void
{
$this->ensureTenantAccount();
abort_unless($device->tenant_id === request()->user()->tenant_id, 404);
}
private function authorizeCreate(): void
{
$this->ensureTenantAccount();
abort_unless(request()->user()->can('device.create'), 403);
abort_if(request()->user()->hasRole(SystemRole::TenantUser->value) && ! $this->policy()->tenant_user_can_create, 403);
}
private function authorizeUpdate(Device $device): void
{
abort_unless(request()->user()->can('device.update'), 403);
abort_if(request()->user()->hasRole(SystemRole::TenantUser->value) && (! $this->policy()->tenant_user_can_update_own || $device->created_by !== request()->user()->id), 403);
}
private function validateModelCombination(Request $request): void
{
if ($request->filled('device_model_id')) {
abort_unless(DeviceModel::whereKey($request->integer('device_model_id'))->where('device_vendor_id', $request->integer('device_vendor_id'))->where('device_type_id', $request->integer('device_type_id'))->exists(), 422, 'Model tidak sesuai dengan vendor dan tipe perangkat.');
}
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreDeviceCredentialRequest;
use App\Http\Requests\UpdateDeviceCredentialRequest;
use App\Models\Device;
use App\Models\DeviceCredential;
use Illuminate\Http\RedirectResponse;
class DeviceCredentialController extends Controller
{
public function store(StoreDeviceCredentialRequest $request, Device $device): RedirectResponse
{
$this->ensureOwned($device);
$data = $request->validated();
$data['is_master'] = $data['privilege_type'] === 'master' || ($data['is_master'] ?? false);
abort_if(($data['is_master'] ?? false) && ! $request->user()->can('device.master_account.manage'), 403);
DeviceCredential::create($data + ['tenant_id' => $request->user()->tenant_id, 'device_id' => $device->id, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
return back()->with('success', 'Credential perangkat berhasil disimpan secara terenkripsi.');
}
public function update(UpdateDeviceCredentialRequest $request, Device $device, DeviceCredential $credential): RedirectResponse
{
$this->ensureOwned($device, $credential);
abort_if($credential->is_master && ! $request->user()->can('device.master_account.manage'), 403);
$data = $request->validated();
$data['is_master'] = $data['privilege_type'] === 'master' || ($data['is_master'] ?? false);
if (blank($data['password'] ?? null)) {
unset($data['password']);
}
$credential->update($data + ['updated_by' => $request->user()->id]);
return back()->with('success', 'Credential perangkat berhasil diperbarui.');
}
public function destroy(Device $device, DeviceCredential $credential): RedirectResponse
{
$this->ensureOwned($device, $credential);
abort_unless(request()->user()->can('device.credential.delete'), 403);
abort_if($credential->is_master && ! request()->user()->can('device.master_account.manage'), 403);
$credential->delete();
return back()->with('success', 'Credential perangkat berhasil dihapus.');
}
private function ensureOwned(Device $device, ?DeviceCredential $credential = null): void
{
abort_if(request()->user()->is_platform_admin || $device->tenant_id !== request()->user()->tenant_id, 404);
abort_if($credential && ($credential->tenant_id !== $device->tenant_id || $credential->device_id !== $device->id), 404);
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\SyncDeviceUser;
use App\Models\DeviceUserAssignment;
use Illuminate\Http\RedirectResponse;
class DeviceUserSyncController extends Controller
{
public function __invoke(DeviceUserAssignment $assignment): RedirectResponse
{
abort_unless(request()->user()->can('device.user.update'), 403);
abort_unless($assignment->tenant_id === request()->user()->tenant_id, 404);
$assignment->update(['sync_status' => 'queued', 'error_code' => null, 'message' => null]);
SyncDeviceUser::dispatch(request()->user()->tenant_id, $assignment->id);
return back()->with('success', 'Sinkronisasi ulang dimasukkan ke queue.');
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileDeleteRequest;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
/**
* Show the user's profile settings page.
*/
public function edit(Request $request): Response
{
return Inertia::render('settings/profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
return to_route('profile.edit');
}
/**
* Delete the user's profile.
*/
public function destroy(ProfileDeleteRequest $request): RedirectResponse
{
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\PasswordUpdateRequest;
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Fortify\Features;
class SecurityController extends Controller
{
/**
* Show the user's security settings page.
*/
public function edit(TwoFactorAuthenticationRequest $request): Response
{
$props = [
'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(),
'canManagePasskeys' => Features::canManagePasskeys(),
'passkeys' => Features::canManagePasskeys()
? $request->user()
->passkeys()
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
->latest()
->get()
->map(fn ($passkey) => [
'id' => $passkey->id,
'name' => $passkey->name,
'authenticator' => $passkey->authenticator,
'created_at_diff' => $passkey->created_at->diffForHumans(),
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
])
->values()
->all()
: [],
'passwordRules' => Password::defaults()->toPasswordRulesString(),
];
if (Features::canManageTwoFactorAuthentication()) {
$request->ensureStateIsValid();
$props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication();
$props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
return Inertia::render('settings/security', $props);
}
/**
* Update the user's password.
*/
public function update(PasswordUpdateRequest $request): RedirectResponse
{
$request->user()->update([
'password' => $request->password,
]);
Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]);
return back();
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SystemRole;
use App\Models\TenantDevicePolicy;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class TenantDevicePolicyController extends Controller
{
public function __invoke(Request $request): RedirectResponse
{
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
$data = $request->validate([
'tenant_user_can_create' => ['required', 'boolean'],
'tenant_user_can_update_own' => ['required', 'boolean'],
'tenant_user_can_delete_own' => ['required', 'boolean'],
]);
TenantDevicePolicy::updateOrCreate(['tenant_id' => $request->user()->tenant_id], $data);
return back()->with('success', 'Aturan perangkat Tenant User berhasil diperbarui.');
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SystemRole;
use App\Models\TenantDeviceSetting;
use App\Models\TenantEncryptionKey;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Inertia\Response;
class TenantDeviceSettingController extends Controller
{
public function edit(Request $request): Response
{
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
$setting = TenantDeviceSetting::where('tenant_id', $request->user()->tenant_id)->first();
$key = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $request->user()->tenant_id)->where('is_active', true)->first();
return Inertia::render('devices/settings', [
'setting' => $setting?->only(['base_username', 'use_tls', 'verify_tls', 'connection_timeout']),
'hasPassword' => (bool) $setting,
'encryptionKey' => $key?->only(['version', 'source', 'created_at']),
]);
}
public function update(Request $request): RedirectResponse
{
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
$setting = TenantDeviceSetting::where('tenant_id', $request->user()->tenant_id)->first();
$data = $request->validate([
'base_username' => ['required', 'regex:/^[A-Za-z0-9][A-Za-z0-9_.@#-]*[A-Za-z0-9]$/', 'max:64'],
'base_password' => [$setting ? 'nullable' : 'required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
'use_tls' => ['required', 'boolean'], 'verify_tls' => ['required', 'boolean'],
'connection_timeout' => ['required', 'integer', 'between:3,30'],
]);
if (blank($data['base_password'] ?? null)) {
unset($data['base_password']);
}
TenantDeviceSetting::updateOrCreate(['tenant_id' => $request->user()->tenant_id], $data);
return back()->with('success', 'Base User perangkat berhasil disimpan secara terenkripsi.');
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SystemRole;
use App\Services\Encryption\TenantEnvelopeEncryption;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
class TenantEncryptionController extends Controller
{
public function __invoke(Request $request, TenantEnvelopeEncryption $encryption): RedirectResponse
{
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
$data = $request->validate([
'mode' => ['required', 'in:generated,manual'],
'manual_key' => ['nullable', 'required_if:mode,manual', 'string', 'min:32', 'max:4096', 'confirmed'],
'current_password' => ['required', 'string'],
]);
abort_unless(Hash::check($data['current_password'], $request->user()->password), 422, 'Password akun Tenant Admin tidak valid.');
$key = $encryption->rotate($request->user()->tenant_id, $data['mode'] === 'manual' ? $data['manual_key'] : null, $request->user()->id);
return back()->with('success', "Encryption key tenant berhasil dirotasi ke versi {$key->version}. Semua secret database telah dienkripsi ulang.");
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureActiveUser
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if ($request->user() && ! $request->user()->is_active) {
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->withErrors(['email' => 'Akun Anda sedang dinonaktifkan.']);
}
return $next($request);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
return [
...parent::share($request),
'name' => config('app.name'),
'auth' => [
'user' => $request->user(),
'permissions' => fn () => $request->user()?->getAllPermissions()->pluck('name')->values() ?? [],
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Support\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SelectAdministrationTenant
{
public function __construct(private readonly TenantContext $context) {}
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$tenant = $request->route('tenant');
abort_unless($tenant instanceof Tenant, 404);
$user = $request->user();
abort_unless($user?->is_platform_admin || $user?->tenant_id === $tenant->id, 403);
$previousTenantId = $this->context->id();
$this->context->set($tenant->id);
setPermissionsTeamId($tenant->id);
try {
return $next($request);
} finally {
$this->context->set($previousTenantId);
setPermissionsTeamId($previousTenantId);
}
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Middleware;
use App\Support\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SetTenantContext
{
public function __construct(private readonly TenantContext $context) {}
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$requestedTenantId = $request->header('X-Tenant-ID');
if ($user?->is_platform_admin && $requestedTenantId !== null) {
abort_unless($user->can('tenant.access-any'), 403);
$this->context->set((int) $requestedTenantId);
} else {
$this->context->set($user?->tenant_id);
}
setPermissionsTeamId($this->context->id());
try {
return $next($request);
} finally {
$this->context->clear();
setPermissionsTeamId(null);
}
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Administration;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class ResetUserPasswordRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('user.update') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Requests\Administration;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class StoreTenantRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('tenant.create') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'alpha_dash', 'max:100', 'unique:tenants,slug'],
'is_active' => ['sometimes', 'boolean'],
'owner_name' => ['required', 'string', 'max:255'],
'owner_email' => ['required', 'email', 'max:255', 'unique:users,email'],
'owner_password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
'deployment_type' => ['sometimes', 'in:managed_cloud,self_hosted'],
'deployment_domain' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Administration;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('user.create') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
'is_active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Administration;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTenantRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('tenant.update') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'alpha_dash', 'max:100', 'unique:tenants,slug,'.$this->route('tenant')->id],
'is_active' => ['required', 'boolean'],
'deployment_type' => ['sometimes', 'in:managed_cloud,self_hosted'],
'deployment_domain' => ['nullable', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Administration;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('user.update') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->route('user')->id)],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class PasswordUpdateRequest extends FormRequest
{
use PasswordValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Settings;
use App\Concerns\PasswordValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProfileDeleteRequest extends FormRequest
{
use PasswordValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'password' => $this->currentPasswordRules(),
];
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Settings;
use App\Concerns\ProfileValidationRules;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProfileUpdateRequest extends FormRequest
{
use ProfileValidationRules;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return $this->profileRules($this->user()->id);
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Fortify\InteractsWithTwoFactorState;
class TwoFactorAuthenticationRequest extends FormRequest
{
use InteractsWithTwoFactorState;
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class StoreDeviceAccessUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('device.user.create') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return $this->rulesForUser();
}
protected function rulesForUser(?int $ignoreId = null): array
{
$tenantId = $this->user()->tenant_id;
return [
'display_name' => ['required', 'string', 'max:255'],
'username' => ['required', 'regex:/^[A-Za-z0-9][A-Za-z0-9_.@#-]*[A-Za-z0-9]$/', 'max:64', Rule::unique('device_access_users')->where('tenant_id', $tenantId)->ignore($ignoreId)],
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
'group_name' => ['required', 'in:RADIQ-READ,RADIQ-WRITE,RADIQ-NOC,RADIQ-MANAGER'],
'device_ids' => ['required', 'array', 'min:1'],
'device_ids.*' => ['integer', Rule::exists('devices', 'id')->where('tenant_id', $tenantId)],
'is_enabled' => ['sometimes', 'boolean'], 'notes' => ['nullable', 'string', 'max:5000'],
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreDeviceCredentialRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('device.credential.create') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return self::deviceCredentialRules();
}
public static function deviceCredentialRules(): array
{
return [
'name' => ['required', 'string', 'max:255'], 'username' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'max:4096'],
'connection_type' => ['required', 'in:routeros_api,ssh,telnet,snmp,vendor_api'],
'privilege_type' => ['required', 'in:master,noc,technician,monitoring,custom'],
'is_master' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreDeviceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('device.create') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return self::deviceRules();
}
public static function deviceRules(): array
{
return [
'name' => ['required', 'string', 'max:255'], 'hostname' => ['nullable', 'string', 'max:255'],
'device_vendor_id' => ['required', 'exists:device_vendors,id'], 'device_type_id' => ['required', 'exists:device_types,id'],
'device_model_id' => ['nullable', 'exists:device_models,id'], 'serial_number' => ['nullable', 'string', 'max:255'],
'management_address' => ['required', 'string', 'max:255'],
'connection_type' => ['required', 'in:routeros_api,ssh,telnet,snmp,vendor_api'],
'management_port' => ['required', 'integer', 'between:1,65535'], 'location' => ['nullable', 'string', 'max:255'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'],
'firmware_version' => ['nullable', 'string', 'max:255'], 'software_version' => ['nullable', 'string', 'max:255'],
'notes' => ['nullable', 'string', 'max:5000'], 'is_active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreTenantRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('tenant.create') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'alpha_dash', 'max:100', 'unique:tenants,slug'],
'is_active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use App\Models\DeviceAccessUser;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rules\Password;
class UpdateDeviceAccessUserRequest extends StoreDeviceAccessUserRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('device.user.update') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
/** @var DeviceAccessUser $deviceAccessUser */
$deviceAccessUser = $this->route('deviceAccessUser');
$rules = $this->rulesForUser($deviceAccessUser->id);
$rules['password'] = ['nullable', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()];
return $rules;
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateDeviceCredentialRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('device.credential.update') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = StoreDeviceCredentialRequest::deviceCredentialRules();
$rules['password'] = ['nullable', 'string', 'max:4096'];
return $rules;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateDeviceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('device.update') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return StoreDeviceRequest::deviceRules();
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateTenantRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->can('tenant.update') ?? false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['sometimes', 'required', 'string', 'max:255'],
'slug' => ['sometimes', 'required', 'alpha_dash', 'max:100', 'unique:tenants,slug,'.$this->route('tenant')->id],
'is_active' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TenantResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'uuid' => $this->uuid,
'name' => $this->name,
'slug' => $this->slug,
'is_active' => $this->is_active,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Jobs;
use App\Models\DeviceUserAssignment;
use App\Models\TenantDeviceSetting;
use App\Network\Clients\RouterOs\RouterOsApiClient;
use App\Network\Drivers\Mikrotik\MikrotikDriver;
use App\Support\TenantContext;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;
class SyncDeviceUser implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public readonly int $tenantId, public readonly int $assignmentId) {}
public function handle(TenantContext $context): void
{
$context->set($this->tenantId);
setPermissionsTeamId($this->tenantId);
try {
$assignment = DeviceUserAssignment::with(['device.vendor', 'device.credentials', 'accessUser'])->find($this->assignmentId);
if (! $assignment || $assignment->tenant_id !== $this->tenantId) {
return;
}
if ($assignment->device->vendor->slug !== 'mikrotik' || $assignment->device->connection_type !== 'routeros_api') {
$assignment->update(['sync_status' => 'unsupported', 'error_code' => 'DRIVER_NOT_AVAILABLE', 'message' => 'Driver user perangkat belum tersedia untuk vendor ini.']);
return;
}
$setting = TenantDeviceSetting::where('tenant_id', $this->tenantId)->first();
if (! $setting) {
$assignment->update(['sync_status' => 'pending', 'error_code' => 'BASE_SETTING_REQUIRED', 'message' => 'Menunggu Base User perangkat dikonfigurasi.']);
return;
}
$credential = $assignment->device->credentials->where('is_active', true)->sortByDesc('is_master')->first();
if (! $credential) {
$assignment->update(['sync_status' => 'pending', 'error_code' => 'CREDENTIAL_REQUIRED', 'message' => 'Menunggu credential aktif perangkat.']);
return;
}
$assignment->update(['sync_status' => 'processing', 'attempts' => $assignment->attempts + 1, 'last_attempted_at' => now(), 'error_code' => null, 'message' => null]);
$driver = new MikrotikDriver(new RouterOsApiClient($assignment->device->management_address, $assignment->device->management_port, $credential->username, $credential->password, $setting->connection_timeout, $setting->use_tls, $setting->verify_tls));
if ($assignment->desired_operation === 'delete') {
$driver->deleteUser($assignment->accessUser->username);
$result = [];
} else {
$result = $driver->syncUser($assignment->accessUser->username, $assignment->accessUser->password, $assignment->group_name, $assignment->accessUser->is_enabled);
}
$assignment->update(['sync_status' => 'synced', 'remote_id' => $result['remote_id'] ?? $assignment->remote_id, 'last_synced_at' => now(), 'error_code' => null, 'message' => 'Sinkronisasi berhasil.']);
$assignment->device->update(['status' => 'online', 'last_seen_at' => now()]);
} catch (Throwable $exception) {
report($exception);
$code = str($exception->getMessage())->before(':')->limit(64)->toString();
$pending = in_array($code, ['DEVICE_UNREACHABLE', 'CONNECTION_TIMEOUT', 'CREDENTIAL_REQUIRED'], true);
DeviceUserAssignment::whereKey($this->assignmentId)->update(['sync_status' => $pending ? 'pending' : 'failed', 'error_code' => $code ?: 'UNKNOWN_ERROR', 'message' => $pending ? 'Perangkat belum dapat dijangkau; akan dicoba kembali.' : 'Sinkronisasi gagal tanpa membuka data sensitif.']);
if ($pending) {
DeviceUserAssignment::whereKey($this->assignmentId)->first()?->device()->update(['status' => 'offline']);
}
} finally {
$context->clear();
setPermissionsTeamId(null);
}
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Models\Concerns;
use App\Models\Tenant;
use App\Support\TenantContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $builder): void {
$tenantId = app(TenantContext::class)->id();
if ($tenantId !== null) {
$builder->where($builder->qualifyColumn('tenant_id'), $tenantId);
}
});
static::creating(function (self $model): void {
$tenantId = app(TenantContext::class)->id();
if ($tenantId !== null && $model->getAttribute('tenant_id') === null) {
$model->setAttribute('tenant_id', $tenantId);
}
});
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Enums\DeploymentMode;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class DeploymentInstallation extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'name', 'deployment_type', 'domain', 'instance_key_hash', 'fingerprint_hash', 'status', 'activated_at', 'last_seen_at'];
protected static function booted(): void
{
static::creating(fn (self $installation) => $installation->uuid ??= (string) Str::uuid());
}
protected function casts(): array
{
return ['deployment_type' => DeploymentMode::class, 'activated_at' => 'immutable_datetime', 'last_seen_at' => 'immutable_datetime'];
}
public function licenses(): HasMany
{
return $this->hasMany(License::class);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Device extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'name', 'hostname', 'device_vendor_id', 'device_type_id', 'device_model_id', 'serial_number', 'management_address', 'connection_type', 'management_port', 'location', 'latitude', 'longitude', 'firmware_version', 'software_version', 'status', 'last_seen_at', 'notes', 'is_active', 'created_by', 'updated_by', 'activation_status', 'activated_at', 'activation_message'];
protected $hidden = ['credentials'];
protected static function booted(): void
{
static::creating(fn (Device $device) => $device->uuid ??= (string) Str::uuid());
}
protected function casts(): array
{
return ['is_active' => 'boolean', 'last_seen_at' => 'datetime', 'activated_at' => 'datetime', 'latitude' => 'decimal:7', 'longitude' => 'decimal:7'];
}
public function vendor()
{
return $this->belongsTo(DeviceVendor::class, 'device_vendor_id');
}
public function type()
{
return $this->belongsTo(DeviceType::class, 'device_type_id');
}
public function model()
{
return $this->belongsTo(DeviceModel::class, 'device_model_id');
}
public function credentials()
{
return $this->hasMany(DeviceCredential::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
public function userAssignments()
{
return $this->hasMany(DeviceUserAssignment::class);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use App\Casts\TenantEncrypted;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class DeviceAccessUser extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = ['tenant_id', 'display_name', 'username', 'password', 'is_enabled', 'notes', 'created_by', 'updated_by'];
protected $hidden = ['password'];
protected static function booted(): void
{
static::creating(fn (self $user) => $user->uuid ??= (string) Str::uuid());
}
protected function casts(): array
{
return ['password' => TenantEncrypted::class, 'is_enabled' => 'boolean'];
}
public function assignments()
{
return $this->hasMany(DeviceUserAssignment::class);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use App\Casts\TenantEncrypted;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class DeviceCredential extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'device_id', 'name', 'username', 'password', 'connection_type', 'privilege_type', 'is_master', 'is_active', 'last_verified_at', 'created_by', 'updated_by'];
protected $hidden = ['password'];
protected static function booted(): void
{
static::creating(fn (DeviceCredential $credential) => $credential->uuid ??= (string) Str::uuid());
}
protected function casts(): array
{
return ['password' => TenantEncrypted::class, 'is_master' => 'boolean', 'is_active' => 'boolean', 'last_verified_at' => 'datetime'];
}
public function device()
{
return $this->belongsTo(Device::class);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DeviceModel extends Model
{
protected $fillable = ['device_vendor_id', 'device_type_id', 'name', 'model_code', 'description', 'is_active'];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function vendor()
{
return $this->belongsTo(DeviceVendor::class, 'device_vendor_id');
}
public function type()
{
return $this->belongsTo(DeviceType::class, 'device_type_id');
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DeviceType extends Model
{
protected $fillable = ['name', 'slug', 'is_active'];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function models()
{
return $this->hasMany(DeviceModel::class);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class DeviceUserAssignment extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'device_access_user_id', 'device_id', 'group_name', 'desired_operation', 'sync_status', 'remote_id', 'error_code', 'message', 'attempts', 'last_attempted_at', 'last_synced_at'];
protected static function booted(): void
{
static::creating(fn (self $assignment) => $assignment->uuid ??= (string) Str::uuid());
}
protected function casts(): array
{
return ['last_attempted_at' => 'datetime', 'last_synced_at' => 'datetime'];
}
public function accessUser()
{
return $this->belongsTo(DeviceAccessUser::class, 'device_access_user_id')->withTrashed();
}
public function device()
{
return $this->belongsTo(Device::class);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DeviceVendor extends Model
{
protected $fillable = ['name', 'slug', 'description', 'is_active'];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function models()
{
return $this->hasMany(DeviceModel::class);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use App\Enums\LicenseStatus;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class License extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'deployment_installation_id', 'license_key_hash', 'plan', 'status', 'max_devices', 'max_users', 'features', 'signed_payload', 'starts_at', 'expires_at', 'grace_until', 'issued_by'];
protected static function booted(): void
{
static::creating(fn (self $license) => $license->uuid ??= (string) Str::uuid());
}
protected function casts(): array
{
return ['status' => LicenseStatus::class, 'features' => 'array', 'starts_at' => 'immutable_datetime', 'expires_at' => 'immutable_datetime', 'grace_until' => 'immutable_datetime'];
}
public function installation(): BelongsTo
{
return $this->belongsTo(DeploymentInstallation::class, 'deployment_installation_id');
}
public function issuer(): BelongsTo
{
return $this->belongsTo(User::class, 'issued_by');
}
public function events(): HasMany
{
return $this->hasMany(LicenseEvent::class);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class LicenseEvent extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'license_id', 'deployment_installation_id', 'actor_id', 'event_type', 'metadata', 'occurred_at'];
protected static function booted(): void
{
static::creating(fn (self $event) => $event->uuid ??= (string) Str::uuid());
}
protected function casts(): array
{
return ['metadata' => 'array', 'occurred_at' => 'immutable_datetime'];
}
public function license(): BelongsTo
{
return $this->belongsTo(License::class);
}
public function installation(): BelongsTo
{
return $this->belongsTo(DeploymentInstallation::class, 'deployment_installation_id');
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Role;
class Tenant extends Model
{
use HasFactory;
protected $fillable = ['name', 'slug', 'is_active'];
protected static function booted(): void
{
static::creating(function (Tenant $tenant): void {
$tenant->uuid ??= (string) Str::uuid();
});
}
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function roles(): HasMany
{
return $this->hasMany(Role::class);
}
public function installations(): HasMany
{
return $this->hasMany(DeploymentInstallation::class);
}
public function licenses(): HasMany
{
return $this->hasMany(License::class);
}
public function devices(): HasMany
{
return $this->hasMany(Device::class);
}
public function devicePolicy()
{
return $this->hasOne(TenantDevicePolicy::class);
}
public function deviceSetting()
{
return $this->hasOne(TenantDeviceSetting::class);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class TenantDevicePolicy extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'tenant_user_can_create', 'tenant_user_can_update_own', 'tenant_user_can_delete_own'];
protected function casts(): array
{
return ['tenant_user_can_create' => 'boolean', 'tenant_user_can_update_own' => 'boolean', 'tenant_user_can_delete_own' => 'boolean'];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Models;
use App\Casts\TenantEncrypted;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
class TenantDeviceSetting extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'base_username', 'base_password', 'use_tls', 'verify_tls', 'connection_timeout'];
protected $hidden = ['base_password'];
protected function casts(): array
{
return ['base_password' => TenantEncrypted::class, 'use_tls' => 'boolean', 'verify_tls' => 'boolean', 'connection_timeout' => 'integer'];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class TenantEncryptionKey extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'version', 'wrapped_key', 'is_active', 'source', 'created_by', 'retired_at'];
protected $hidden = ['wrapped_key'];
protected static function booted(): void
{
static::creating(fn (self $key) => $key->uuid ??= (string) Str::uuid());
}
protected function casts(): array
{
return ['is_active' => 'boolean', 'retired_at' => 'immutable_datetime'];
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
/**
* @property int $id
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string $password
* @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property Carbon|null $two_factor_confirmed_at
* @property string|null $remember_token
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['tenant_id', 'name', 'email', 'password', 'is_active'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements PasskeyUser
{
/** @use HasFactory<UserFactory> */
use BelongsToTenant, HasApiTokens, HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
protected static function booted(): void
{
static::creating(function (User $user): void {
$user->uuid ??= (string) Str::uuid();
});
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
'is_platform_admin' => 'boolean',
'is_active' => 'boolean',
];
}
}
@@ -0,0 +1,161 @@
<?php
namespace App\Network\Clients\RouterOs;
use RuntimeException;
class RouterOsApiClient
{
/** @var resource|null */
private $socket;
public function __construct(
private readonly string $host,
private readonly int $port,
private readonly string $username,
private readonly string $password,
private readonly int $timeout = 10,
private readonly bool $tls = false,
private readonly bool $verifyTls = true,
) {}
public function connect(): void
{
$transport = $this->tls ? 'tls' : 'tcp';
$context = stream_context_create(['ssl' => ['verify_peer' => $this->verifyTls, 'verify_peer_name' => $this->verifyTls, 'SNI_enabled' => true]]);
$socket = @stream_socket_client("{$transport}://{$this->host}:{$this->port}", $errorNumber, $errorMessage, $this->timeout, STREAM_CLIENT_CONNECT, $context);
if (! is_resource($socket)) {
throw new RuntimeException('DEVICE_UNREACHABLE: koneksi RouterOS API gagal.');
}
$this->socket = $socket;
stream_set_timeout($this->socket, $this->timeout);
$this->command(['/login', '=name='.$this->username, '=password='.$this->password]);
}
public function disconnect(): void
{
if (is_resource($this->socket)) {
fclose($this->socket);
}
$this->socket = null;
}
/** @return list<array<string, string>> */
public function command(array $words): array
{
if (! is_resource($this->socket)) {
throw new RuntimeException('DRIVER_NOT_CONNECTED');
}
foreach ($words as $word) {
$this->writeWord($word);
}
$this->writeWord('');
$rows = [];
while (true) {
$sentence = $this->readSentence();
$type = array_shift($sentence);
if ($type === '!trap' || $type === '!fatal') {
throw new RuntimeException('COMMAND_REJECTED: RouterOS menolak operasi.');
}
if ($type === '!re') {
$rows[] = $this->attributes($sentence);
}
if ($type === '!done') {
return $rows;
}
}
}
private function writeWord(string $word): void
{
$length = strlen($word);
$prefix = match (true) {
$length < 0x80 => chr($length),
$length < 0x4000 => pack('n', $length | 0x8000),
$length < 0x200000 => substr(pack('N', $length | 0xC0000000), 1),
$length < 0x10000000 => pack('N', $length | 0xE0000000),
default => chr(0xF0).pack('N', $length),
};
$this->writeAll($prefix.$word);
}
/** @return list<string> */
private function readSentence(): array
{
$words = [];
while (($word = $this->readWord()) !== '') {
$words[] = $word;
}
return $words;
}
private function readWord(): string
{
$length = $this->readLength();
return $length === 0 ? '' : $this->readBytes($length);
}
private function readLength(): int
{
$first = ord($this->readBytes(1));
if (($first & 0x80) === 0) {
return $first;
}
if (($first & 0xC0) === 0x80) {
return (($first & 0x3F) << 8) + ord($this->readBytes(1));
}
if (($first & 0xE0) === 0xC0) {
$bytes = $this->readBytes(2);
return (($first & 0x1F) << 16) + (ord($bytes[0]) << 8) + ord($bytes[1]);
}
if (($first & 0xF0) === 0xE0) {
$bytes = $this->readBytes(3);
return (($first & 0x0F) << 24) + (ord($bytes[0]) << 16) + (ord($bytes[1]) << 8) + ord($bytes[2]);
}
return unpack('N', $this->readBytes(4))[1];
}
private function readBytes(int $length): string
{
$data = '';
while (strlen($data) < $length) {
$chunk = fread($this->socket, $length - strlen($data));
if ($chunk === false || $chunk === '') {
throw new RuntimeException('CONNECTION_TIMEOUT: respons RouterOS tidak lengkap.');
}
$data .= $chunk;
}
return $data;
}
private function writeAll(string $data): void
{
while ($data !== '') {
$written = fwrite($this->socket, $data);
if ($written === false || $written === 0) {
throw new RuntimeException('CONNECTION_FAILED');
}
$data = substr($data, $written);
}
}
private function attributes(array $words): array
{
$attributes = [];
foreach ($words as $word) {
if (str_starts_with($word, '=')) {
[, $key, $value] = array_pad(explode('=', $word, 3), 3, '');
$attributes[$key] = $value;
}
}
return $attributes;
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Network\Contracts;
interface DeviceDriverInterface
{
public function testConnection(): array;
public function provisionBaseAccess(string $username, string $password): array;
public function syncUser(string $username, string $password, string $group, bool $enabled): array;
public function deleteUser(string $username): void;
}
@@ -0,0 +1,85 @@
<?php
namespace App\Network\Drivers\Mikrotik;
use App\Network\Clients\RouterOs\RouterOsApiClient;
use App\Network\Contracts\DeviceDriverInterface;
class MikrotikDriver implements DeviceDriverInterface
{
private const GROUPS = [
'RADIQ-READ' => 'local,ssh,read,test,winbox,api',
'RADIQ-WRITE' => 'local,ssh,read,write,test,winbox,password,api',
'RADIQ-NOC' => 'local,ssh,read,write,test,winbox,password,api',
'RADIQ-MANAGER' => 'local,ssh,read,write,policy,test,winbox,password,sensitive,api',
];
public function __construct(private readonly RouterOsApiClient $client) {}
public function testConnection(): array
{
$this->client->connect();
try {
return $this->client->command(['/system/identity/print', '=.proplist=name'])[0] ?? [];
} finally {
$this->client->disconnect();
}
}
public function provisionBaseAccess(string $username, string $password): array
{
$this->client->connect();
try {
foreach (self::GROUPS as $name => $policies) {
$existing = $this->client->command(['/user/group/print', '?name='.$name, '=.proplist=.id']);
if ($existing === []) {
$this->client->command(['/user/group/add', '=name='.$name, '=policy='.$policies, '=comment=Managed by RADIQ NDM']);
} else {
$this->client->command(['/user/group/set', '=.id='.$existing[0]['.id'], '=policy='.$policies, '=comment=Managed by RADIQ NDM']);
}
}
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
if ($users === []) {
$this->client->command(['/user/add', '=name='.$username, '=password='.$password, '=group=RADIQ-MANAGER', '=disabled=no', '=comment=Managed by RADIQ NDM']);
} else {
$this->client->command(['/user/set', '=.id='.$users[0]['.id'], '=password='.$password, '=group=RADIQ-MANAGER', '=disabled=no', '=comment=Managed by RADIQ NDM']);
}
return ['groups' => array_keys(self::GROUPS), 'username' => $username];
} finally {
$this->client->disconnect();
}
}
public function syncUser(string $username, string $password, string $group, bool $enabled): array
{
$this->client->connect();
try {
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
if ($users === []) {
$this->client->command(['/user/add', '=name='.$username, '=password='.$password, '=group='.$group, '=disabled='.($enabled ? 'no' : 'yes'), '=comment=Managed by RADIQ NDM']);
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
} else {
$this->client->command(['/user/set', '=.id='.$users[0]['.id'], '=password='.$password, '=group='.$group, '=disabled='.($enabled ? 'no' : 'yes'), '=comment=Managed by RADIQ NDM']);
}
return ['remote_id' => $users[0]['.id'] ?? null];
} finally {
$this->client->disconnect();
}
}
public function deleteUser(string $username): void
{
$this->client->connect();
try {
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
if ($users !== []) {
$this->client->command(['/user/remove', '=.id='.$users[0]['.id']]);
}
} finally {
$this->client->disconnect();
}
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Models\Tenant;
use App\Models\User;
class TenantPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->can('tenant.view');
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Tenant $tenant): bool
{
return $user->can('tenant.view') && ($user->tenant_id === $tenant->id || $user->is_platform_admin);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->is_platform_admin && $user->can('tenant.create');
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Tenant $tenant): bool
{
return $user->can('tenant.update') && ($user->tenant_id === $tenant->id || $user->is_platform_admin);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Tenant $tenant): bool
{
return $user->is_platform_admin && $user->can('tenant.delete');
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Tenant $tenant): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Tenant $tenant): bool
{
return false;
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Providers;
use App\Support\TenantContext;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->singleton(TenantContext::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Gate::before(fn ($user): ?bool => $user->is_platform_admin && config('deployment.mode') !== 'self_hosted' ? true : null);
$this->configureDefaults();
}
/**
* Configure default behaviors for production-ready applications.
*/
protected function configureDefaults(): void
{
Date::use(CarbonImmutable::class);
DB::prohibitDestructiveCommands(
app()->isProduction(),
);
Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12)
->mixedCase()
->letters()
->numbers()
->symbols()
->uncompromised()
: null,
);
}
}
@@ -0,0 +1,106 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Inertia\Inertia;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::authenticateUsing(function (Request $request): ?User {
$user = User::query()->withoutGlobalScope('tenant')->where('email', $request->string('email'))->first();
return $user && $user->is_active && Hash::check($request->string('password'), $user->password)
? $user
: null;
});
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
'canResetPassword' => Features::enabled(Features::resetPasswords()),
'status' => $request->session()->get('status'),
]));
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/reset-password', [
'email' => $request->email,
'token' => $request->route('token'),
'passwordRules' => Password::defaults()->toPasswordRulesString(),
]));
Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/forgot-password', [
'status' => $request->session()->get('status'),
]));
Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/verify-email', [
'status' => $request->session()->get('status'),
]));
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('passkeys', function (Request $request) {
return Limit::perMinute(10)->by(
($request->input('credential.id') ?: $request->session()->getId()).'|'.$request->ip(),
);
});
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Devices;
use App\Models\Device;
use App\Models\TenantDeviceSetting;
use App\Network\Clients\RouterOs\RouterOsApiClient;
use App\Network\Drivers\Mikrotik\MikrotikDriver;
use RuntimeException;
class MikrotikActivationService
{
public function activate(Device $device): array
{
abort_unless($device->vendor?->slug === 'mikrotik' && $device->connection_type === 'routeros_api', 422, 'Aktivasi otomatis saat ini hanya mendukung MikroTik RouterOS API.');
$setting = TenantDeviceSetting::where('tenant_id', $device->tenant_id)->first();
if (! $setting) {
throw new RuntimeException('BASE_SETTING_REQUIRED: atur Base User dan Password terlebih dahulu.');
}
$credential = $device->credentials()->where('is_active', true)->orderByDesc('is_master')->first();
if (! $credential) {
throw new RuntimeException('CREDENTIAL_REQUIRED: tambahkan credential login perangkat terlebih dahulu.');
}
$client = new RouterOsApiClient($device->management_address, $device->management_port, $credential->username, $credential->password, $setting->connection_timeout, $setting->use_tls, $setting->verify_tls);
return (new MikrotikDriver($client))->provisionBaseAccess($setting->base_username, $setting->base_password);
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Services\Encryption;
use App\Models\Tenant;
use App\Models\TenantEncryptionKey;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class TenantEnvelopeEncryption
{
private const PREFIX = 'radiq:v1:';
public function ensureKey(int $tenantId, ?int $actorId = null): TenantEncryptionKey
{
return TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->where('is_active', true)->first()
?? $this->createKey($tenantId, null, $actorId);
}
public function encrypt(int $tenantId, string $plaintext, ?TenantEncryptionKey $key = null): string
{
$key ??= $this->ensureKey($tenantId);
$dek = Crypt::decryptString($key->wrapped_key);
$nonce = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, $nonce, $tag, (string) $tenantId);
if ($ciphertext === false) {
throw new RuntimeException('ENCRYPTION_FAILED');
}
return self::PREFIX.$key->id.':'.base64_encode($nonce).':'.base64_encode($tag).':'.base64_encode($ciphertext);
}
public function decrypt(int $tenantId, string $payload): string
{
if (! str_starts_with($payload, self::PREFIX)) {
return Crypt::decryptString($payload);
}
$parts = explode(':', $payload, 6);
if (count($parts) !== 6) {
throw new RuntimeException('INVALID_ENCRYPTED_PAYLOAD');
}
$key = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->findOrFail((int) $parts[2]);
$dek = Crypt::decryptString($key->wrapped_key);
$plaintext = openssl_decrypt(base64_decode($parts[5], true), 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, base64_decode($parts[3], true), base64_decode($parts[4], true), (string) $tenantId);
if ($plaintext === false) {
throw new RuntimeException('DECRYPTION_FAILED');
}
return $plaintext;
}
public function rotate(int $tenantId, ?string $manualKey, int $actorId): TenantEncryptionKey
{
return DB::transaction(function () use ($tenantId, $manualKey, $actorId): TenantEncryptionKey {
Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail();
$oldKeys = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->lockForUpdate()->get();
$newKey = $this->createKey($tenantId, $manualKey, $actorId, ($oldKeys->max('version') ?? 0) + 1);
foreach (DB::table('device_credentials')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'password']) as $credential) {
DB::table('device_credentials')->where('id', $credential->id)->update(['password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $credential->password), $newKey), 'updated_at' => now()]);
}
foreach (DB::table('tenant_device_settings')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'base_password']) as $setting) {
DB::table('tenant_device_settings')->where('id', $setting->id)->update(['base_password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $setting->base_password), $newKey), 'updated_at' => now()]);
}
foreach (DB::table('device_access_users')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'password']) as $accessUser) {
DB::table('device_access_users')->where('id', $accessUser->id)->update(['password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $accessUser->password), $newKey), 'updated_at' => now()]);
}
TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->where('id', '!=', $newKey->id)->where('is_active', true)->update(['is_active' => false, 'retired_at' => now()]);
return $newKey;
});
}
private function createKey(int $tenantId, ?string $manualKey, ?int $actorId, int $version = 1): TenantEncryptionKey
{
$tenant = Tenant::findOrFail($tenantId);
$dek = $manualKey === null ? random_bytes(32) : hash_hkdf('sha256', $manualKey, 32, 'RADIQ-NDM:'.$tenant->uuid);
return TenantEncryptionKey::withoutGlobalScope('tenant')->create([
'tenant_id' => $tenantId, 'version' => $version, 'wrapped_key' => Crypt::encryptString($dek),
'is_active' => true, 'source' => $manualKey === null ? 'generated' : 'manual', 'created_by' => $actorId,
]);
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Services;
use App\Enums\SystemRole;
use App\Models\DeploymentInstallation;
use App\Models\Tenant;
use App\Models\TenantDevicePolicy;
use App\Models\User;
use App\Services\Encryption\TenantEnvelopeEncryption;
use App\Support\SystemRolePermissions;
use App\Support\TenantContext;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Role;
class TenantProvisioningService
{
public function __construct(private readonly TenantContext $context) {}
/** @param array{name:string,slug:string,is_active?:bool,owner_name:string,owner_email:string,owner_password:string} $data */
public function createWithOwner(array $data): Tenant
{
return DB::transaction(function () use ($data): Tenant {
$tenant = Tenant::create([
'name' => $data['name'],
'slug' => $data['slug'],
'is_active' => $data['is_active'] ?? true,
]);
TenantDevicePolicy::create(['tenant_id' => $tenant->id]);
app(TenantEnvelopeEncryption::class)->ensureKey($tenant->id);
$deploymentType = $data['deployment_type'] ?? 'managed_cloud';
DeploymentInstallation::create([
'tenant_id' => $tenant->id,
'name' => $deploymentType === 'self_hosted' ? 'Server Tenant' : 'RADIQ Managed Cloud',
'deployment_type' => $deploymentType,
'domain' => $data['deployment_domain'] ?? null,
'instance_key_hash' => hash('sha256', random_bytes(32)),
'status' => $deploymentType === 'managed_cloud' ? 'active' : 'pending',
'activated_at' => $deploymentType === 'managed_cloud' ? now() : null,
]);
$this->context->set($tenant->id);
setPermissionsTeamId($tenant->id);
try {
$roles = $this->createDefaultRoles($tenant);
$owner = User::create([
'tenant_id' => $tenant->id,
'name' => $data['owner_name'],
'email' => $data['owner_email'],
'password' => $data['owner_password'],
'is_active' => true,
]);
$owner->forceFill(['email_verified_at' => now()])->save();
$owner->assignRole($roles[SystemRole::TenantAdmin->value]);
} finally {
$this->context->clear();
setPermissionsTeamId(null);
}
return $tenant;
});
}
/** @return array<string, Role> */
private function createDefaultRoles(Tenant $tenant): array
{
$definitions = SystemRolePermissions::tenantRoles();
$roles = [];
foreach ($definitions as $name => $permissions) {
$role = Role::create(['tenant_id' => $tenant->id, 'name' => $name, 'guard_name' => 'web']);
$role->syncPermissions($permissions);
$roles[$name] = $role;
}
return $roles;
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Support;
use App\Enums\SystemRole;
final class SystemRolePermissions
{
/** @return array<string, list<string>> */
public static function tenantRoles(): array
{
return [
SystemRole::TenantAdmin->value => [
'user.view', 'user.create', 'user.update', 'user.delete',
'device.view', 'device.create', 'device.update', 'device.delete', 'device.connect',
'device.credential.view', 'device.credential.create', 'device.credential.update', 'device.credential.delete',
'device.user.view', 'device.user.create', 'device.user.update', 'device.user.delete', 'device.user.mass_update',
'device.master_account.manage', 'device.command.execute', 'device.command.mass_execute',
'device.config.view', 'device.config.deploy', 'device.backup.create', 'device.backup.download',
'device.backup.restore', 'device.monitoring.view', 'audit.view', 'license.view', 'installation.view',
],
SystemRole::TenantUser->value => [
'device.view', 'device.create', 'device.update', 'device.connect',
'device.user.view', 'device.user.create', 'device.user.update',
'device.command.execute', 'device.config.view', 'device.backup.create',
'device.backup.download', 'device.monitoring.view',
],
];
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Support;
final class TenantContext
{
private ?int $tenantId = null;
public function set(?int $tenantId): void
{
$this->tenantId = $tenantId;
}
public function id(): ?int
{
return $this->tenantId;
}
public function clear(): void
{
$this->tenantId = null;
}
}