735 lines
28 KiB
PHP
735 lines
28 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\Api;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
use App\Http\Requests\StoreServerResourceRequest;
|
||
use App\Http\Requests\UpdateServerResourceRequest;
|
||
use App\Models\AccessLog;
|
||
use App\Models\BastionAccount;
|
||
use App\Models\OpsProtocol;
|
||
use App\Models\ServerResource;
|
||
use App\Models\User;
|
||
use App\Models\UserServerPermission;
|
||
use hg\apidoc\annotation as Apidoc;
|
||
use Illuminate\Http\Client\ConnectionException;
|
||
use Illuminate\Http\Client\RequestException;
|
||
use Illuminate\Http\JsonResponse;
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Collection;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\Validation\ValidationException;
|
||
use Spatie\Permission\Models\Permission;
|
||
|
||
#[Apidoc\Title('服务器资源管理')]
|
||
class ServerResourceController extends Controller
|
||
{
|
||
public function __construct()
|
||
{
|
||
$this->middleware('auth:api');
|
||
$this->middleware('permission:platform.servers.view|resource.servers.use,api')->only(['index', 'show']);
|
||
$this->middleware('permission:platform.servers.manage,api')->only(['store', 'update', 'destroy', 'syncUserPermissions', 'userPermissions']);
|
||
}
|
||
|
||
#[Apidoc\Title('资源列表'), Apidoc\Method('GET'), Apidoc\Url('/servers')]
|
||
public function index(): JsonResponse
|
||
{
|
||
$this->syncAllResourcePermissions();
|
||
$query = ServerResource::query()->with('parent')->latest();
|
||
$user = auth('api')->user();
|
||
|
||
if ($user && ! $user->can('platform.servers.view')) {
|
||
$pivotResourceIds = $user->serverResources()
|
||
->where(function ($pivotQuery) {
|
||
$pivotQuery->where('can_ssh', true)
|
||
->orWhere('can_sftp', true)
|
||
->orWhere('can_rdp', true);
|
||
})
|
||
->pluck('server_resources.id')
|
||
->values();
|
||
|
||
$permissionResourceIds = $this->resolveResourceIdsFromPermissions($user);
|
||
$resourceIds = $pivotResourceIds
|
||
->merge($permissionResourceIds)
|
||
->map(fn ($id): int => (int) $id)
|
||
->unique()
|
||
->values();
|
||
|
||
$parentIds = ServerResource::query()
|
||
->whereIn('id', $resourceIds)
|
||
->pluck('parent_id')
|
||
->filter()
|
||
->values();
|
||
|
||
$query->where(function ($scope) use ($resourceIds, $parentIds) {
|
||
$scope->whereIn('id', $resourceIds)->orWhereIn('id', $parentIds);
|
||
});
|
||
}
|
||
|
||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $query->paginate(500)]);
|
||
}
|
||
|
||
private function resolveResourceIdsFromPermissions(User $user): Collection
|
||
{
|
||
$allPermissions = $user->getAllPermissions();
|
||
if ($allPermissions->contains(fn (Permission $permission): bool => $permission->name === 'resource.servers.use')) {
|
||
return ServerResource::query()
|
||
->whereNotNull('parent_id')
|
||
->pluck('id')
|
||
->values();
|
||
}
|
||
|
||
$resourceIds = collect();
|
||
foreach ($allPermissions as $permission) {
|
||
$permissionName = (string) $permission->name;
|
||
if (! str_starts_with($permissionName, 'resource.servers.use.')) {
|
||
continue;
|
||
}
|
||
|
||
$description = (string) ($permission->description ?? '');
|
||
if (preg_match('/资源ID[::]\s*(\d+)/u', $description, $matches) === 1) {
|
||
$resourceIds->push((int) $matches[1]);
|
||
continue;
|
||
}
|
||
|
||
$nameParts = explode('.', $permissionName);
|
||
if (count($nameParts) < 6) {
|
||
continue;
|
||
}
|
||
|
||
$serverName = trim((string) ($nameParts[3] ?? ''));
|
||
$resourceName = trim((string) ($nameParts[4] ?? ''));
|
||
if ($serverName === '' || $resourceName === '') {
|
||
continue;
|
||
}
|
||
|
||
$matchedIds = ServerResource::query()
|
||
->from('server_resources as resource')
|
||
->join('server_resources as server', 'server.id', '=', 'resource.parent_id')
|
||
->where('server.name', $serverName)
|
||
->where('resource.name', $resourceName)
|
||
->pluck('resource.id')
|
||
->map(fn ($id): int => (int) $id)
|
||
->all();
|
||
if (! empty($matchedIds)) {
|
||
$resourceIds = $resourceIds->merge($matchedIds);
|
||
}
|
||
}
|
||
|
||
return $resourceIds->unique()->values();
|
||
}
|
||
|
||
#[Apidoc\Title('创建资源'), Apidoc\Method('POST'), Apidoc\Url('/servers')]
|
||
public function store(StoreServerResourceRequest $request): JsonResponse
|
||
{
|
||
$data = $request->validated();
|
||
$isResource = ! empty($data['parent_id']);
|
||
$targetParentId = $isResource ? (int) $data['parent_id'] : null;
|
||
|
||
$this->ensureUniqueNameUnderParent((string) $data['name'], $targetParentId, null);
|
||
|
||
if ($isResource) {
|
||
$parent = ServerResource::query()->findOrFail($targetParentId);
|
||
$data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip;
|
||
$data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), null)];
|
||
$data['display_name'] = $data['display_name'] ?? $data['name'];
|
||
$data['asset_id'] = $parent->asset_id;
|
||
|
||
if (empty($data['asset_id'])) {
|
||
throw ValidationException::withMessages([
|
||
'parent_id' => ['所属服务器未配置 asset_id,请先配置服务器的 asset_id。'],
|
||
]);
|
||
}
|
||
|
||
if (empty($data['account_id'])) {
|
||
throw ValidationException::withMessages([
|
||
'account_id' => ['资源必须填写 account_id。'],
|
||
]);
|
||
}
|
||
} else {
|
||
$data['protocols'] = [];
|
||
$data['account_id'] = null;
|
||
$data['display_name'] = $data['display_name'] ?? $data['name'];
|
||
}
|
||
|
||
unset($data['protocol']);
|
||
|
||
$server = ServerResource::query()->create($data);
|
||
$this->syncResourcePermission($server->load('parent'));
|
||
$this->auditLog($request, $isResource ? 'resource_create' : 'server_create', ['server_resource_id' => $server->id]);
|
||
|
||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $server], 201);
|
||
}
|
||
|
||
#[Apidoc\Title('资源详情'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}')]
|
||
public function show(int $id): JsonResponse
|
||
{
|
||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => ServerResource::query()->with('parent')->findOrFail($id)]);
|
||
}
|
||
|
||
#[Apidoc\Title('更新资源'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}')]
|
||
public function update(UpdateServerResourceRequest $request, int $id): JsonResponse
|
||
{
|
||
$server = ServerResource::query()->findOrFail($id);
|
||
$server->load('children');
|
||
$data = $request->validated();
|
||
$isResource = ! empty($data['parent_id']) || ! empty($server->parent_id);
|
||
$targetParentId = $isResource ? (int) ($data['parent_id'] ?? $server->parent_id) : null;
|
||
|
||
$this->ensureUniqueNameUnderParent((string) $data['name'], $targetParentId, (int) $server->id);
|
||
|
||
if ($isResource) {
|
||
$parent = ServerResource::query()->findOrFail($targetParentId);
|
||
$data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip;
|
||
$data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), (string) ($server->protocols[0] ?? ''))];
|
||
$data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']);
|
||
$data['asset_id'] = $parent->asset_id;
|
||
|
||
if (empty($data['asset_id'])) {
|
||
throw ValidationException::withMessages([
|
||
'parent_id' => ['所属服务器未配置 asset_id,请先配置服务器的 asset_id。'],
|
||
]);
|
||
}
|
||
|
||
if (empty($data['account_id'])) {
|
||
throw ValidationException::withMessages([
|
||
'account_id' => ['资源必须填写 account_id。'],
|
||
]);
|
||
}
|
||
} else {
|
||
$data['protocols'] = [];
|
||
$data['account_id'] = null;
|
||
$data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']);
|
||
}
|
||
|
||
unset($data['protocol']);
|
||
$server->update($data);
|
||
$server->refresh()->load(['parent', 'children.parent']);
|
||
$this->syncResourcePermission($server);
|
||
if (! $server->parent_id) {
|
||
ServerResource::query()
|
||
->where('parent_id', $server->id)
|
||
->update(['asset_id' => $server->asset_id]);
|
||
foreach ($server->children as $childResource) {
|
||
$this->syncResourcePermission($childResource->load('parent'));
|
||
}
|
||
}
|
||
|
||
$this->auditLog($request, $isResource ? 'resource_update' : 'server_update', ['server_resource_id' => $server->id]);
|
||
|
||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $server]);
|
||
}
|
||
|
||
#[Apidoc\Title('删除资源'), Apidoc\Method('DELETE'), Apidoc\Url('/servers/{id}')]
|
||
public function destroy(Request $request, int $id): JsonResponse
|
||
{
|
||
$server = ServerResource::query()->with('children')->findOrFail($id);
|
||
$this->auditLog($request, $server->parent_id ? 'resource_delete' : 'server_delete', ['server_resource_id' => $server->id]);
|
||
if (! $server->parent_id) {
|
||
$descendantIds = $this->collectDescendantResourceIds((int) $server->id);
|
||
|
||
foreach ($descendantIds as $descendantId) {
|
||
$this->deleteResourcePermission($descendantId);
|
||
}
|
||
|
||
ServerResource::query()
|
||
->whereIn('id', $descendantIds)
|
||
->delete();
|
||
}
|
||
$this->deleteResourcePermission((int) $server->id);
|
||
$server->delete();
|
||
|
||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
|
||
}
|
||
|
||
#[Apidoc\Title('资源用户权限'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}/user-permissions')]
|
||
public function userPermissions(Request $request, int $id): JsonResponse
|
||
{
|
||
$server = ServerResource::query()->findOrFail($id);
|
||
$assignedOnly = $request->boolean('assigned_only', false);
|
||
|
||
$users = User::query()->select(['id', 'nickname', 'email'])->with(['serverResources' => function ($query) use ($id) {
|
||
$query->where('server_resource_id', $id);
|
||
}])->orderBy('id')->get()->map(function (User $user) {
|
||
$pivot = $user->serverResources->first()?->pivot;
|
||
|
||
return [
|
||
'id' => $user->id,
|
||
'nickname' => $user->nickname,
|
||
'email' => $user->email,
|
||
'can_ssh' => (bool) ($pivot->can_ssh ?? false),
|
||
'can_sftp' => (bool) ($pivot->can_sftp ?? false),
|
||
'can_rdp' => (bool) ($pivot->can_rdp ?? false),
|
||
];
|
||
})->filter(function (array $userItem) use ($assignedOnly) {
|
||
if (! $assignedOnly) {
|
||
return true;
|
||
}
|
||
|
||
return (bool) ($userItem['can_ssh'] || $userItem['can_sftp'] || $userItem['can_rdp']);
|
||
})->values();
|
||
|
||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => ['server' => $server, 'users' => $users]]);
|
||
}
|
||
|
||
#[Apidoc\Title('同步资源用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}/user-permissions')]
|
||
public function syncUserPermissions(Request $request, int $id): JsonResponse
|
||
{
|
||
$validated = $request->validate([
|
||
'users' => ['required', 'array'],
|
||
'users.*.id' => ['required', 'integer', 'exists:users,id'],
|
||
'users.*.can_ssh' => ['required', 'boolean'],
|
||
'users.*.can_sftp' => ['required', 'boolean'],
|
||
'users.*.can_rdp' => ['required', 'boolean'],
|
||
'partial' => ['sometimes', 'boolean'],
|
||
]);
|
||
|
||
$server = ServerResource::query()->with('parent')->findOrFail($id);
|
||
$syncData = [];
|
||
|
||
foreach ($validated['users'] as $userItem) {
|
||
$syncData[(int) $userItem['id']] = [
|
||
'can_ssh' => (bool) $userItem['can_ssh'],
|
||
'can_sftp' => (bool) $userItem['can_sftp'],
|
||
'can_rdp' => (bool) $userItem['can_rdp'],
|
||
];
|
||
}
|
||
|
||
$partial = (bool) ($validated['partial'] ?? false);
|
||
if ($partial) {
|
||
foreach ($syncData as $userId => $permissionItem) {
|
||
$server->users()->updateExistingPivot($userId, $permissionItem);
|
||
}
|
||
} else {
|
||
$server->users()->sync($syncData);
|
||
}
|
||
|
||
$permission = $this->syncResourcePermission($server);
|
||
$this->syncDirectPermissionsByPivot($server, $permission, $syncData);
|
||
|
||
$this->auditLog($request, 'resource_user_permissions_update', ['server_resource_id' => $server->id]);
|
||
|
||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
|
||
}
|
||
|
||
#[Apidoc\Title('使用服务器资源'), Apidoc\Method('POST'), Apidoc\Url('/servers/{id}/use')]
|
||
public function useResource(Request $request, int $id): JsonResponse
|
||
{
|
||
$validated = $request->validate([
|
||
'account_name' => ['nullable', 'string', 'max:255'],
|
||
'password' => ['nullable', 'string', 'max:255'],
|
||
'protocol' => ['required', 'string', 'max:64'],
|
||
]);
|
||
|
||
$resource = ServerResource::query()->with('parent')->findOrFail($id);
|
||
if (! $resource->parent_id) {
|
||
throw ValidationException::withMessages([
|
||
'id' => ['请选择具体资源后再发起访问。'],
|
||
]);
|
||
}
|
||
|
||
if (! $resource->is_active) {
|
||
throw ValidationException::withMessages([
|
||
'id' => ['该资源已停用,无法访问。'],
|
||
]);
|
||
}
|
||
|
||
$assetId = (int) ($resource->asset_id ?: ($resource->parent?->asset_id ?: 0));
|
||
$accountId = (int) ($resource->account_id ?: 0);
|
||
|
||
if ($assetId <= 0 || $accountId <= 0) {
|
||
throw ValidationException::withMessages([
|
||
'id' => ['该资源缺少 asset_id 或 account_id,无法访问。'],
|
||
]);
|
||
}
|
||
|
||
$requestedProtocol = (string) $validated['protocol'];
|
||
$resourceProtocols = collect($resource->protocols ?? [])
|
||
->map(fn ($item): string => (string) $item)
|
||
->filter()
|
||
->mapWithKeys(fn (string $item): array => [mb_strtolower($item) => $item])
|
||
->toArray();
|
||
$protocol = $resourceProtocols[mb_strtolower($requestedProtocol)] ?? '';
|
||
if ($protocol === '') {
|
||
throw ValidationException::withMessages([
|
||
'protocol' => ['该资源不支持所选协议。'],
|
||
]);
|
||
}
|
||
|
||
/** @var User|null $user */
|
||
$user = auth('api')->user();
|
||
if (! $user || ! $this->canUseResource($user, $resource, $protocol)) {
|
||
return response()->json([
|
||
'code' => 403,
|
||
'message' => '无权限使用该资源',
|
||
'data' => null,
|
||
], 403);
|
||
}
|
||
|
||
$bastionAccount = BastionAccount::query()
|
||
->where('is_active', true)
|
||
->whereNotNull('usm')
|
||
->where('usm', '!=', '')
|
||
->whereNotNull('usm_authentication')
|
||
->where('usm_authentication', '!=', '')
|
||
->inRandomOrder()
|
||
->first();
|
||
|
||
if (! $bastionAccount) {
|
||
return response()->json([
|
||
'code' => 422,
|
||
'message' => '当前没有可用的堡垒机授权账号,请先刷新账号 Token',
|
||
'data' => null,
|
||
], 422);
|
||
}
|
||
|
||
$baseUrl = (string) config('services.bastion_access.base_url', 'https://172.16.254.2');
|
||
$endpoint = (string) config('services.bastion_access.sso_endpoint', '/usmapi/v1/operation/custom/sso');
|
||
$timeout = (int) config('services.bastion_access.timeout', 30);
|
||
$verifySsl = (bool) config('services.bastion_access.verify_ssl', false);
|
||
$protocolId = $this->resolveProtocolId($protocol);
|
||
$accountName = (string) ($validated['account_name'] ?? '');
|
||
$password = (string) ($validated['password'] ?? '');
|
||
|
||
try {
|
||
$response = Http::baseUrl($baseUrl)
|
||
->acceptJson()
|
||
->timeout($timeout)
|
||
->retry(2, 300, throw: false)
|
||
->withOptions(['verify' => $verifySsl])
|
||
->withHeaders([
|
||
'Cookie' => sprintf(
|
||
'USM=%s; USM-AUTHENTICATION=%s',
|
||
(string) $bastionAccount->usm,
|
||
(string) $bastionAccount->usm_authentication
|
||
),
|
||
])
|
||
->post($endpoint, [
|
||
'accounts' => [[
|
||
'account_name' => $accountName,
|
||
'protocol_id' => $protocolId,
|
||
'asset_id' => $assetId,
|
||
'account_id' => $accountId,
|
||
'rule_id' => 0,
|
||
'password' => $password,
|
||
]],
|
||
'op_type' => 'bs',
|
||
'description' => '',
|
||
'second_verify_token' => '',
|
||
'use_taibao_password' => 0,
|
||
'use_om_password_cache' => 0,
|
||
]);
|
||
} catch (ConnectionException|RequestException $exception) {
|
||
return response()->json([
|
||
'code' => 502,
|
||
'message' => '堡垒机接口调用失败:'.$exception->getMessage(),
|
||
'data' => null,
|
||
], 502);
|
||
}
|
||
|
||
if (! $response->successful()) {
|
||
return response()->json([
|
||
'code' => 502,
|
||
'message' => '堡垒机接口返回异常',
|
||
'data' => [
|
||
'status' => $response->status(),
|
||
'response' => $response->json(),
|
||
],
|
||
], 502);
|
||
}
|
||
|
||
$result = $response->json();
|
||
if (strtoupper((string) data_get($result, 'code')) !== 'SUCCESS') {
|
||
return response()->json([
|
||
'code' => 502,
|
||
'message' => '堡垒机接口返回失败:'.((string) data_get($result, 'msg', '未知错误')),
|
||
'data' => ['response' => $result],
|
||
], 502);
|
||
}
|
||
|
||
$ssoUrl = (string) data_get($result, 'data.items.0.url', '');
|
||
if ($ssoUrl === '') {
|
||
return response()->json([
|
||
'code' => 502,
|
||
'message' => '堡垒机未返回可用的 SSO 地址',
|
||
'data' => ['response' => $result],
|
||
], 502);
|
||
}
|
||
|
||
AccessLog::query()->create([
|
||
'user_id' => $user->id,
|
||
'server_resource_id' => $resource->id,
|
||
'bastion_account_id' => $bastionAccount->id,
|
||
'protocol' => $protocol,
|
||
'action' => 'resource_use',
|
||
'requested_at' => now(),
|
||
'metadata' => [
|
||
'resource_name' => $resource->display_name ?: $resource->name,
|
||
'account_name' => $accountName,
|
||
'has_password' => $password !== '',
|
||
'protocol_id' => $protocolId,
|
||
'bastion_response_code' => data_get($result, 'code'),
|
||
],
|
||
]);
|
||
|
||
$this->auditLog($request, 'resource_use', [
|
||
'server_resource_id' => $resource->id,
|
||
'bastion_account_id' => $bastionAccount->id,
|
||
'metadata' => [
|
||
'protocol' => $protocol,
|
||
'protocol_id' => $protocolId,
|
||
'account_name' => $accountName,
|
||
],
|
||
]);
|
||
|
||
return response()->json([
|
||
'code' => 0,
|
||
'message' => 'ok',
|
||
'data' => [
|
||
'url' => $ssoUrl,
|
||
'protocol' => $protocol,
|
||
'resource_id' => $resource->id,
|
||
'resource_name' => $resource->display_name ?: $resource->name,
|
||
'bastion_account_id' => $bastionAccount->id,
|
||
'client_type' => (string) data_get($result, 'data.client_type', ''),
|
||
'response' => $result,
|
||
],
|
||
]);
|
||
}
|
||
|
||
private function syncDirectPermissionsByPivot(ServerResource $resource, Permission $permission, array $syncData): void
|
||
{
|
||
if (! $resource->parent_id) {
|
||
return;
|
||
}
|
||
|
||
foreach ($syncData as $userId => $permissionItem) {
|
||
$canUseResource = (bool) ($permissionItem['can_ssh'] || $permissionItem['can_sftp'] || $permissionItem['can_rdp']);
|
||
$user = User::query()->find((int) $userId);
|
||
|
||
if (! $user) {
|
||
continue;
|
||
}
|
||
|
||
if ($canUseResource) {
|
||
if (! $user->hasDirectPermission($permission->name)) {
|
||
$user->givePermissionTo($permission);
|
||
}
|
||
} else {
|
||
$user->revokePermissionTo($permission->name);
|
||
}
|
||
}
|
||
}
|
||
|
||
public static function buildResourcePermissionName(ServerResource $resource): string
|
||
{
|
||
$serverName = $resource->parent_id ? (string) ($resource->parent?->name ?: 'unknown-server') : (string) ($resource->name ?: 'unknown-server');
|
||
$resourceName = (string) ($resource->name ?: 'unknown-resource');
|
||
|
||
return sprintf(
|
||
'resource.servers.use.%s.%s',
|
||
trim($serverName),
|
||
trim($resourceName)
|
||
);
|
||
}
|
||
|
||
public static function resourcePermissionDescription(ServerResource $resource): string
|
||
{
|
||
return '服务器资源访问权限(资源ID: '.$resource->id.')';
|
||
}
|
||
|
||
private function syncResourcePermission(ServerResource $resource): Permission
|
||
{
|
||
$description = self::resourcePermissionDescription($resource);
|
||
$permission = Permission::query()
|
||
->where('guard_name', 'api')
|
||
->where('description', $description)
|
||
->first();
|
||
$basePermissionName = self::buildResourcePermissionName($resource);
|
||
|
||
if (! $permission) {
|
||
$permission = Permission::query()->firstOrCreate(
|
||
[
|
||
'guard_name' => 'api',
|
||
'name' => $this->resolvePermissionNameConflict($basePermissionName, null, (int) $resource->id),
|
||
],
|
||
[
|
||
'category' => '资源使用',
|
||
'description' => $description,
|
||
]
|
||
);
|
||
}
|
||
|
||
$permission->update([
|
||
'name' => $this->resolvePermissionNameConflict($basePermissionName, (int) $permission->id, (int) $resource->id),
|
||
'category' => '资源使用',
|
||
'description' => $description,
|
||
]);
|
||
|
||
return $permission;
|
||
}
|
||
|
||
private function deleteResourcePermission(int $resourceId): void
|
||
{
|
||
Permission::query()
|
||
->where('guard_name', 'api')
|
||
->where('description', '服务器资源访问权限(资源ID: '.$resourceId.')')
|
||
->delete();
|
||
|
||
UserServerPermission::query()
|
||
->where('server_resource_id', $resourceId)
|
||
->delete();
|
||
}
|
||
|
||
private function ensureUniqueNameUnderParent(string $name, ?int $parentId, ?int $ignoreId): void
|
||
{
|
||
$query = ServerResource::query()->where('name', $name);
|
||
|
||
if ($parentId === null) {
|
||
$query->whereNull('parent_id');
|
||
} else {
|
||
$query->where('parent_id', $parentId);
|
||
}
|
||
|
||
if ($ignoreId !== null) {
|
||
$query->where('id', '!=', $ignoreId);
|
||
}
|
||
|
||
if ($query->exists()) {
|
||
throw ValidationException::withMessages([
|
||
'name' => ['同一服务器层级下名称必须唯一。'],
|
||
]);
|
||
}
|
||
}
|
||
|
||
private function resolvePermissionNameConflict(string $baseName, ?int $ignorePermissionId, int $resourceId): string
|
||
{
|
||
$query = Permission::query()
|
||
->where('guard_name', 'api')
|
||
->where('name', $baseName);
|
||
|
||
if ($ignorePermissionId !== null) {
|
||
$query->where('id', '!=', $ignorePermissionId);
|
||
}
|
||
|
||
if (! $query->exists()) {
|
||
return $baseName;
|
||
}
|
||
|
||
return $baseName.'.'.$resourceId;
|
||
}
|
||
|
||
private function syncAllResourcePermissions(): void
|
||
{
|
||
$resources = ServerResource::query()->with('parent')->get();
|
||
|
||
foreach ($resources as $resource) {
|
||
$this->syncResourcePermission($resource);
|
||
}
|
||
}
|
||
|
||
private function canUseResource(User $user, ServerResource $resource, string $protocol): bool
|
||
{
|
||
if ($user->can('platform.servers.view') || $user->can('resource.servers.use')) {
|
||
return true;
|
||
}
|
||
|
||
$resourcePermission = Permission::query()
|
||
->where('guard_name', 'api')
|
||
->where('description', self::resourcePermissionDescription($resource))
|
||
->first();
|
||
if ($resourcePermission && $user->hasPermissionTo($resourcePermission->name)) {
|
||
return true;
|
||
}
|
||
|
||
$pivot = $resource->users()
|
||
->where('users.id', $user->id)
|
||
->first()?->pivot;
|
||
if (! $pivot) {
|
||
return false;
|
||
}
|
||
|
||
return match ($protocol) {
|
||
'SFTP' => (bool) $pivot->can_sftp,
|
||
'RDP' => (bool) $pivot->can_rdp,
|
||
default => (bool) $pivot->can_ssh,
|
||
};
|
||
}
|
||
|
||
private function resolveProtocolId(string $protocol): int
|
||
{
|
||
$managed = OpsProtocol::query()
|
||
->where('name', $protocol)
|
||
->first();
|
||
if ($managed) {
|
||
return (int) ($managed->bastion_protocol_id ?: 2);
|
||
}
|
||
|
||
return match (strtoupper($protocol)) {
|
||
'SFTP' => (int) config('services.bastion_access.protocol_ids.sftp', 4),
|
||
'RDP' => (int) config('services.bastion_access.protocol_ids.rdp', 3),
|
||
default => (int) config('services.bastion_access.protocol_ids.ssh', 2),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @return int[]
|
||
*/
|
||
private function collectDescendantResourceIds(int $parentId): array
|
||
{
|
||
$descendantIds = [];
|
||
$queue = [$parentId];
|
||
|
||
while (! empty($queue)) {
|
||
$children = ServerResource::query()
|
||
->whereIn('parent_id', $queue)
|
||
->pluck('id')
|
||
->map(fn (int $id): int => (int) $id)
|
||
->all();
|
||
|
||
if (empty($children)) {
|
||
break;
|
||
}
|
||
|
||
$descendantIds = array_values(array_unique([...$descendantIds, ...$children]));
|
||
$queue = $children;
|
||
}
|
||
|
||
return $descendantIds;
|
||
}
|
||
|
||
private function resolveResourceProtocol(string $requestedProtocol, ?string $fallbackProtocol): string
|
||
{
|
||
$normalizedRequested = trim($requestedProtocol);
|
||
if ($normalizedRequested !== '') {
|
||
$exists = OpsProtocol::query()->where('name', $normalizedRequested)->exists();
|
||
if (! $exists) {
|
||
throw ValidationException::withMessages([
|
||
'protocol' => ['协议不存在,请先在运维协议中配置。'],
|
||
]);
|
||
}
|
||
|
||
return $normalizedRequested;
|
||
}
|
||
|
||
$normalizedFallback = trim((string) $fallbackProtocol);
|
||
if ($normalizedFallback !== '') {
|
||
return $normalizedFallback;
|
||
}
|
||
|
||
$firstProtocol = (string) OpsProtocol::query()
|
||
->where('is_active', true)
|
||
->orderBy('sort')
|
||
->orderBy('id')
|
||
->value('name');
|
||
|
||
if ($firstProtocol === '') {
|
||
throw ValidationException::withMessages([
|
||
'protocol' => ['请先在运维协议中新增并启用至少一个协议。'],
|
||
]);
|
||
}
|
||
|
||
return $firstProtocol;
|
||
}
|
||
}
|