BastionSSO/app/Http/Controllers/Api/ServerResourceController.php
Boen_Shi 550ac11789 feat(资源权限): 重做服务器/资源用户权限分配交互支撑
- userPermissions 接口返回手机号字段用于矩阵展示

- 支持前端按用户-资源开关矩阵进行分配
2026-04-30 12:07:52 +08:00

787 lines
29 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', 'phone'])->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,
'phone' => $user->phone,
'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' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'max:255'],
'protocol' => ['required', 'string', 'max:64'],
], [
'account_name.required' => '请输入访问用户名。',
'password.required' => '请输入访问密码。',
]);
$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);
}
$tempPassword = null;
if ((bool) $resource->allow_copy_temp_password) {
$tempPassword = $this->extractSsoTokenFromUrl($ssoUrl);
}
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,
'allow_copy_temp_password' => (bool) $resource->allow_copy_temp_password,
'temp_password' => $tempPassword,
],
]);
}
private function extractSsoTokenFromUrl(string $ssoUrl): ?string
{
if (! str_starts_with($ssoUrl, 'sso://')) {
return null;
}
$encoded = trim(substr($ssoUrl, strlen('sso://')));
$encoded = rtrim($encoded, '/');
if ($encoded === '') {
return null;
}
$decoded = base64_decode($encoded, true);
if ($decoded === false || $decoded === '') {
return null;
}
$payload = json_decode($decoded, true);
if (! is_array($payload)) {
return null;
}
$token = data_get($payload, 'NODE_COMMON.SSOToken');
if (! is_string($token) || trim($token) === '') {
return null;
}
return trim($token);
}
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
{
$resource->loadMissing('parent');
if (! $resource->parent_id) {
$serverLabel = trim((string) ($resource->display_name ?: $resource->name ?: '未命名服务器'));
return sprintf('服务器资源访问权限(%s资源ID: %d', $serverLabel, (int) $resource->id);
}
$serverLabel = trim((string) ($resource->parent?->display_name ?: $resource->parent?->name ?: '未命名服务器'));
$resourceLabel = trim((string) ($resource->display_name ?: $resource->name ?: '未命名资源'));
return sprintf('服务器资源访问权限(%s-%s资源ID: %d', $serverLabel, $resourceLabel, (int) $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', 'like', '%资源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;
}
}