BastionSSO/app/Http/Controllers/Api/ServerResourceController.php
Boen_Shi 58e44b5e61 fix(服务器资源): 修复用户组继承权限下资源不可见
资源可见性计算新增对权限名的反向解析,支持 resource.servers.use.{服务器名}.{资源名} 形式并映射资源ID。
2026-04-29 14:34:35 +08:00

735 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}