Compare commits

..

3 Commits

51 changed files with 3549 additions and 20 deletions

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ CLAUDE.md
.mcp.json
boost.json
LOG.md
*/.venv*

View File

@ -0,0 +1,31 @@
<?php
namespace App\Console\Commands;
use App\Models\BastionAccount;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class BastionRefreshTokensCommand extends Command
{
protected $signature = 'bastion:refresh-tokens';
protected $description = 'Refresh USM and USM-AUTHENTICATION for active bastion accounts';
public function handle(): int
{
BastionAccount::query()->where('is_active', true)->chunkById(100, function ($accounts): void {
foreach ($accounts as $account) {
$account->update([
'usm_authentication' => Str::uuid()->toString(),
'usm' => hash('sha256', Str::uuid()->toString()),
'last_token_refreshed_at' => now(),
]);
}
});
$this->info('Bastion tokens refreshed.');
return self::SUCCESS;
}
}

View File

@ -0,0 +1,177 @@
<?php
namespace App\Console\Commands;
use App\Models\BastionAccount;
use Illuminate\Console\Command;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class CheckBastionLoginCommand extends Command
{
protected $signature = 'bastion:check-login {--account_id=} {--refresh-invalid}';
protected $description = '检测堡垒机账号 token 是否有效0 有效,-1 无效)';
public function handle(): int
{
$query = BastionAccount::query()
->where('is_active', true)
->whereNotNull('usm')
->where('usm', '!=', '')
->whereNotNull('usm_authentication')
->where('usm_authentication', '!=', '');
$accountId = $this->option('account_id');
if ($accountId !== null && $accountId !== '') {
$query->where('id', (int) $accountId);
}
$accounts = $query->orderBy('id')->get();
if ($accounts->isEmpty()) {
$this->line('[]');
return self::SUCCESS;
}
$rows = [];
foreach ($accounts as $account) {
$status = $this->checkAccountTokenStatus((string) $account->usm_authentication, (string) $account->usm);
$refreshed = false;
if ($status === -1 && $this->option('refresh-invalid')) {
$refreshed = $this->refreshAccountToken($account);
if ($refreshed) {
$account->refresh();
$status = $this->checkAccountTokenStatus((string) $account->usm_authentication, (string) $account->usm);
}
}
$rows[] = [
'id' => $account->id,
'username' => $account->username,
'status' => $status,
'refreshed' => $refreshed,
];
}
$this->line(json_encode($rows, JSON_UNESCAPED_UNICODE));
return self::SUCCESS;
}
private function checkAccountTokenStatus(string $usmAuthentication, string $usm): int
{
$url = 'https://172.16.254.2/index.php/extend/check_login';
$cookie = sprintf(
'LANG=zh; USM-AUTHENTICATION=%s; USM=%s; LOGON=1; AUTH_METHOD=oauth2',
$usmAuthentication,
$usm
);
try {
$response = Http::timeout(10)
->withOptions(['verify' => false])
->withHeaders([
'Cookie' => $cookie,
'Accept' => '*/*',
])
->get($url);
} catch (ConnectionException) {
return -1;
}
if (! $response->successful()) {
return -1;
}
return trim((string) $response->body()) === '0' ? 0 : -1;
}
private function refreshAccountToken(BastionAccount $account): bool
{
$baseUrl = (string) config('services.bastion_token.base_url');
if ($baseUrl === '') {
return false;
}
$submitEndpoint = (string) config('services.bastion_token.submit_endpoint', '/bastion_token');
$statusEndpoint = (string) config('services.bastion_token.status_endpoint', '/bastion_token/{task_id}');
$timeout = (int) config('services.bastion_token.timeout', 30);
$service = (string) config('services.bastion_token.service', 'https://myapp.cdu.edu.cn/index.html');
$verifySsl = (bool) config('services.bastion_token.verify_ssl', false);
try {
$submitResponse = Http::baseUrl($baseUrl)
->acceptJson()
->timeout($timeout)
->retry(2, 300, throw: false)
->post($submitEndpoint, [
'username' => $account->username,
'password' => $account->password,
'service' => $service,
'verify_ssl' => $verifySsl,
]);
} catch (ConnectionException|RequestException) {
return false;
}
if (! $submitResponse->successful()) {
return false;
}
$taskId = (string) data_get($submitResponse->json(), 'task_id', '');
if ($taskId === '') {
return false;
}
$maxPoll = 20;
for ($i = 0; $i < $maxPoll; $i++) {
usleep(500000);
$statusUrl = str_replace('{task_id}', $taskId, $statusEndpoint);
try {
$statusResponse = Http::baseUrl($baseUrl)
->acceptJson()
->timeout($timeout)
->retry(2, 300, throw: false)
->get($statusUrl);
} catch (ConnectionException|RequestException) {
continue;
}
if (! $statusResponse->successful()) {
continue;
}
$taskResult = $statusResponse->json();
$status = strtolower((string) data_get($taskResult, 'status', 'pending'));
if ($status === 'pending') {
continue;
}
if ($status === 'error') {
return false;
}
$usmAuthentication = (string) data_get($taskResult, 'data.bastion.token_cookies.USM-AUTHENTICATION', '');
$usm = (string) data_get($taskResult, 'data.bastion.token_cookies.USM', '');
if ($usmAuthentication === '' || $usm === '') {
$usmAuthentication = (string) data_get($taskResult, 'data.USM-AUTHENTICATION', $usmAuthentication);
$usm = (string) data_get($taskResult, 'data.USM', $usm);
}
if ($usmAuthentication === '' || $usm === '') {
return false;
}
$account->update([
'usm_authentication' => $usmAuthentication,
'usm' => $usm,
'last_token_refreshed_at' => now(),
]);
return true;
}
return false;
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class InstallApplicationCommand extends Command
{
protected $signature = 'app:install
{--fresh : Drop all tables and re-run all migrations}
{--force : Force install commands in production}
{--admin-email=admin@example.com : Super admin email}
{--admin-phone=admin : Super admin phone}
{--admin-nickname=admin : Super admin nickname}
{--admin-password=admin : Super admin password}';
protected $description = 'Install app with migration, default RBAC data, and super admin account';
public function handle(): int
{
$this->components->info('Starting application installation...');
$migrationExitCode = $this->call(
$this->option('fresh') ? 'migrate:fresh' : 'migrate',
['--force' => (bool) $this->option('force')]
);
if ($migrationExitCode !== self::SUCCESS) {
$this->error('Database migration failed.');
return self::FAILURE;
}
$rbacExitCode = $this->call('user:manage', [
'action' => 'init-rbac',
]);
if ($rbacExitCode !== self::SUCCESS) {
$this->error('Default RBAC initialization failed.');
return self::FAILURE;
}
$adminEmail = trim((string) $this->option('admin-email'));
$adminPhone = trim((string) $this->option('admin-phone'));
$adminNickname = trim((string) $this->option('admin-nickname'));
$adminPassword = (string) $this->option('admin-password');
if ($adminEmail === '' || $adminNickname === '' || $adminPassword === '') {
$this->error('admin-email, admin-nickname and admin-password are required.');
return self::FAILURE;
}
$adminUser = User::query()->updateOrCreate(
['email' => $adminEmail],
[
'nickname' => $adminNickname,
'phone' => $adminPhone !== '' ? $adminPhone : null,
'password' => $adminPassword,
'force_password_change' => false,
]
);
$setAdminExitCode = $this->call('user:manage', [
'action' => 'set-admin',
'--email' => $adminUser->email,
]);
if ($setAdminExitCode !== self::SUCCESS) {
$this->error('Failed to set admin role and permissions.');
return self::FAILURE;
}
$this->newLine();
$this->components->info('Application installed successfully.');
$this->line("Admin email: {$adminUser->email}");
if ($adminUser->phone) {
$this->line("Admin phone: {$adminUser->phone}");
}
$this->line("Admin password: {$adminPassword}");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,376 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\UserServerPermission;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class UserManageCommand extends Command
{
protected $signature = 'user:manage
{action : create|reset-password|list|init-rbac|create-role|assign-role|remove-role|assign-permission|remove-permission|grant-server|set-admin|unset-admin}
{--email= : User email}
{--nickname= : User nickname}
{--phone= : User phone}
{--password= : User password}
{--role= : Role name}
{--permission= : Permission name}
{--server-id= : Server resource id}
{--ssh=0 : Grant ssh 1|0}
{--sftp=0 : Grant sftp 1|0}
{--rdp=0 : Grant rdp 1|0}';
protected $description = 'User and permission management command';
public function handle(): int
{
return match ($this->argument('action')) {
'create' => $this->createUser(),
'reset-password' => $this->resetPassword(),
'list' => $this->listUsers(),
'init-rbac' => $this->initRbac(),
'create-role' => $this->createRole(),
'assign-role' => $this->assignRole(),
'remove-role' => $this->removeRole(),
'assign-permission' => $this->assignPermission(),
'remove-permission' => $this->removePermission(),
'grant-server' => $this->grantServerPermission(),
'set-admin' => $this->setAdmin(),
'unset-admin' => $this->unsetAdmin(),
default => $this->invalidAction(),
};
}
private function createUser(): int
{
$email = (string) $this->option('email');
$nickname = (string) $this->option('nickname');
$password = (string) $this->option('password');
if ($email === '' || $nickname === '' || $password === '') {
$this->error('email, nickname, password are required.');
return self::FAILURE;
}
$user = User::query()->create([
'email' => $email,
'nickname' => $nickname,
'phone' => $this->option('phone') ?: null,
'password' => $password,
]);
if ($this->option('role')) {
$role = Role::query()->firstOrCreate([
'name' => (string) $this->option('role'),
'guard_name' => 'api',
]);
$user->assignRole($role);
}
$this->info("User created: {$user->id}");
return self::SUCCESS;
}
private function resetPassword(): int
{
$user = $this->findUserByEmail();
if (! $user) {
return self::FAILURE;
}
$password = (string) $this->option('password');
if ($password === '') {
$this->error('password is required.');
return self::FAILURE;
}
$user->password = Hash::make($password);
$user->save();
$this->info('Password reset success.');
return self::SUCCESS;
}
private function listUsers(): int
{
$rows = User::query()->with(['roles', 'permissions'])->latest()->get()->map(fn (User $user): array => [
'id' => $user->id,
'nickname' => $user->nickname,
'email' => $user->email,
'roles' => $user->roles->pluck('name')->implode(','),
'permissions' => $user->permissions->pluck('name')->implode(','),
])->toArray();
$this->table(['ID', 'Nickname', 'Email', 'Roles', 'Permissions'], $rows);
return self::SUCCESS;
}
private function initRbac(): int
{
$permissions = [
'platform.users.view',
'platform.users.manage',
'platform.roles.view',
'platform.roles.manage',
'platform.permissions.view',
'platform.permissions.manage',
'platform.servers.view',
'platform.servers.manage',
'platform.accounts.view',
'platform.accounts.manage',
'platform.logs.view',
'platform.logs.manage',
'platform.oauth_clients.view',
'platform.oauth_clients.manage',
'platform.oauth_scopes.view',
'platform.oauth_scopes.manage',
'resource.servers.use',
];
foreach ($permissions as $permissionName) {
Permission::query()->firstOrCreate([
'name' => $permissionName,
'guard_name' => 'api',
]);
}
$adminRole = Role::query()->firstOrCreate([
'name' => 'admin',
'guard_name' => 'api',
]);
$userRole = Role::query()->firstOrCreate([
'name' => 'user',
'guard_name' => 'api',
]);
$adminRole->syncPermissions($permissions);
$userRole->syncPermissions([
'resource.servers.use',
]);
Role::query()->where('guard_name', 'api')->whereIn('name', ['operator', 'member'])->delete();
$this->info('RBAC initialized.');
return self::SUCCESS;
}
private function createRole(): int
{
$roleName = (string) $this->option('role');
if ($roleName === '') {
$this->error('role is required.');
return self::FAILURE;
}
Role::query()->firstOrCreate([
'name' => $roleName,
'guard_name' => 'api',
]);
$this->info('Role created.');
return self::SUCCESS;
}
private function assignRole(): int
{
$user = $this->findUserByEmail();
if (! $user) {
return self::FAILURE;
}
$roleName = (string) $this->option('role');
if ($roleName === '') {
$this->error('role is required.');
return self::FAILURE;
}
$user->assignRole($roleName);
$this->info('Role assigned.');
return self::SUCCESS;
}
private function removeRole(): int
{
$user = $this->findUserByEmail();
if (! $user) {
return self::FAILURE;
}
$roleName = (string) $this->option('role');
if ($roleName === '') {
$this->error('role is required.');
return self::FAILURE;
}
$user->removeRole($roleName);
$this->info('Role removed.');
return self::SUCCESS;
}
private function assignPermission(): int
{
$user = $this->findUserByEmail();
if (! $user) {
return self::FAILURE;
}
$permission = (string) $this->option('permission');
if ($permission === '') {
$this->error('permission is required.');
return self::FAILURE;
}
$user->givePermissionTo($permission);
$this->info('Permission assigned.');
return self::SUCCESS;
}
private function removePermission(): int
{
$user = $this->findUserByEmail();
if (! $user) {
return self::FAILURE;
}
$permission = (string) $this->option('permission');
if ($permission === '') {
$this->error('permission is required.');
return self::FAILURE;
}
$user->revokePermissionTo($permission);
$this->info('Permission removed.');
return self::SUCCESS;
}
private function grantServerPermission(): int
{
$user = $this->findUserByEmail();
if (! $user) {
return self::FAILURE;
}
$serverId = (int) $this->option('server-id');
if ($serverId <= 0) {
$this->error('server-id is required and must be > 0.');
return self::FAILURE;
}
UserServerPermission::query()->updateOrCreate(
['user_id' => $user->id, 'server_resource_id' => $serverId],
[
'can_ssh' => (bool) ((int) $this->option('ssh')),
'can_sftp' => (bool) ((int) $this->option('sftp')),
'can_rdp' => (bool) ((int) $this->option('rdp')),
]
);
$this->info('Server permission updated.');
return self::SUCCESS;
}
private function setAdmin(): int
{
$user = $this->findUserByEmail();
if (! $user) {
return self::FAILURE;
}
$adminRole = Role::query()->firstOrCreate([
'name' => 'admin',
'guard_name' => 'api',
]);
$allPermissions = Permission::query()
->where('guard_name', 'api')
->pluck('name')
->all();
$adminRole->syncPermissions($allPermissions);
$user->syncRoles([$adminRole->name]);
$user->syncPermissions([]);
$this->info('User set as admin with all permissions.');
return self::SUCCESS;
}
private function unsetAdmin(): int
{
$user = $this->findUserByEmail();
if (! $user) {
return self::FAILURE;
}
if (! $user->hasRole('admin', 'api')) {
$this->warn('User is not admin.');
return self::SUCCESS;
}
$user->removeRole('admin');
$this->info('Admin role removed from user.');
return self::SUCCESS;
}
private function findUserByEmail(): ?User
{
$email = (string) $this->option('email');
if ($email === '') {
$this->error('email is required.');
return null;
}
$user = User::query()->where('email', $email)->first();
if (! $user) {
$this->error('User not found.');
return null;
}
return $user;
}
private function invalidAction(): int
{
$this->error('Invalid action.');
return self::FAILURE;
}
}

View File

@ -70,10 +70,7 @@ class OpsClientController extends Controller
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name'],
'bastion_protocol_id' => ['required', 'integer', 'min:1'],
'description' => ['nullable', 'string', 'max:255'],
'sort' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
]);
$protocol = OpsProtocol::query()->create($validated);
@ -88,10 +85,7 @@ class OpsClientController extends Controller
$protocol = OpsProtocol::query()->findOrFail($id);
$validated = $request->validate([
'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name,'.$protocol->id],
'bastion_protocol_id' => ['required', 'integer', 'min:1'],
'description' => ['nullable', 'string', 'max:255'],
'sort' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
]);
$protocol->update($validated);
@ -116,8 +110,6 @@ class OpsClientController extends Controller
OpsProtocol::query()->findOrFail($id);
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'client_path' => ['nullable', 'string', 'max:255'],
'sort' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
]);
@ -146,8 +138,6 @@ class OpsClientController extends Controller
$software = OpsSoftware::query()->findOrFail($id);
$validated = $request->validate([
'name' => ['required', 'string', 'max:100'],
'client_path' => ['nullable', 'string', 'max:255'],
'sort' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
]);

View File

@ -9,6 +9,7 @@ use App\Models\AccessLog;
use App\Models\BastionAccount;
use App\Models\OpsProtocol;
use App\Models\ServerResource;
use App\Models\ServerUserBinding;
use App\Models\User;
use App\Models\UserServerPermission;
use hg\apidoc\annotation as Apidoc;
@ -66,7 +67,10 @@ class ServerResourceController extends Controller
});
}
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $query->paginate(500)]);
$resources = $query->paginate(500);
$this->appendServerUserManagementMeta($resources->getCollection(), $user);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $resources]);
}
private function resolveResourceIdsFromPermissions(User $user): Collection
@ -125,6 +129,8 @@ class ServerResourceController extends Controller
if ($isResource) {
$parent = ServerResource::query()->findOrFail($targetParentId);
$data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip;
$data['user_api_base_url'] = null;
$data['user_api_token'] = null;
$data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), null)];
$data['display_name'] = $data['display_name'] ?? $data['name'];
$data['asset_id'] = $parent->asset_id;
@ -144,6 +150,9 @@ class ServerResourceController extends Controller
$data['protocols'] = [];
$data['account_id'] = null;
$data['display_name'] = $data['display_name'] ?? $data['name'];
if (trim((string) ($data['user_api_token'] ?? '')) === '') {
$data['user_api_token'] = null;
}
}
unset($data['protocol']);
@ -158,7 +167,10 @@ class ServerResourceController extends Controller
#[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)]);
$resource = ServerResource::query()->with('parent')->findOrFail($id);
$this->appendServerUserManagementMeta(collect([$resource]), auth('api')->user());
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $resource]);
}
#[Apidoc\Title('更新资源'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}')]
@ -175,6 +187,8 @@ class ServerResourceController extends Controller
if ($isResource) {
$parent = ServerResource::query()->findOrFail($targetParentId);
$data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip;
$data['user_api_base_url'] = null;
$data['user_api_token'] = null;
$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;
@ -194,6 +208,9 @@ class ServerResourceController extends Controller
$data['protocols'] = [];
$data['account_id'] = null;
$data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']);
if (trim((string) ($data['user_api_token'] ?? '')) === '') {
unset($data['user_api_token']);
}
}
unset($data['protocol']);
@ -493,6 +510,7 @@ class ServerResourceController extends Controller
'protocol' => $protocol,
'resource_id' => $resource->id,
'resource_name' => $resource->display_name ?: $resource->name,
'server_username' => $this->boundServerUsername($user, $resource),
'bastion_account_id' => $bastionAccount->id,
'client_type' => (string) data_get($result, 'data.client_type', ''),
'response' => $result,
@ -502,6 +520,41 @@ class ServerResourceController extends Controller
]);
}
private function appendServerUserManagementMeta(Collection $resources, ?User $user): void
{
$serverIds = $resources
->map(fn (ServerResource $resource): int => (int) ($resource->parent_id ?: $resource->id))
->unique()
->values();
$bindings = collect();
if ($user && $serverIds->isNotEmpty()) {
$bindings = ServerUserBinding::query()
->where('user_id', $user->id)
->whereIn('server_resource_id', $serverIds)
->get()
->keyBy('server_resource_id');
}
foreach ($resources as $resource) {
$server = $resource->parent_id ? $resource->parent : $resource;
$serverId = (int) ($server?->id ?: $resource->id);
$binding = $bindings->get($serverId);
$resource->setAttribute('user_api_configured', trim((string) ($server?->user_api_base_url ?? $resource->user_api_base_url ?? '')) !== '');
$resource->setAttribute('server_username', $binding?->username);
}
}
private function boundServerUsername(User $user, ServerResource $resource): ?string
{
$serverId = (int) ($resource->parent_id ?: $resource->id);
return ServerUserBinding::query()
->where('user_id', $user->id)
->where('server_resource_id', $serverId)
->value('username');
}
private function extractSsoTokenFromUrl(string $ssoUrl): ?string
{
if (! str_starts_with($ssoUrl, 'sso://')) {

View File

@ -0,0 +1,307 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ServerResource;
use App\Models\ServerUserBinding;
use App\Models\User;
use App\Services\ServerUserManagementClient;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
#[Apidoc\Title('服务器用户与用户组管理')]
class ServerSystemUserController extends Controller
{
public function __construct(private ServerUserManagementClient $client)
{
$this->middleware('auth:api');
$this->middleware('permission:platform.servers.manage,api')->except(['updateBoundPassword']);
}
#[Apidoc\Title('修改当前用户绑定服务器账号密码'), Apidoc\Method('PATCH'), Apidoc\Url('/servers/{id}/bound-system-user/password')]
public function updateBoundPassword(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'password' => ['required', 'string', 'min:6', 'max:255'],
]);
$server = $this->server($id);
$resource = ServerResource::query()->with('parent')->findOrFail($id);
/** @var User|null $user */
$user = auth('api')->user();
if (! $user || ! $this->canUpdateBoundPassword($user, $resource)) {
return response()->json([
'code' => 403,
'message' => '无权限修改该服务器账号密码',
'data' => null,
], 403);
}
$binding = ServerUserBinding::query()
->where('user_id', $user->id)
->where('server_resource_id', $server->id)
->first();
if (! $binding || trim((string) $binding->username) === '') {
throw ValidationException::withMessages([
'server' => ['当前用户未绑定该服务器账号。'],
]);
}
$result = $this->client->updatePassword($server, (string) $binding->username, $this->linuxPasswordHash((string) $validated['password']));
$binding->update([
'remote_exists' => true,
'last_synced_at' => now(),
]);
$this->auditLog($request, 'server_bound_user_password_update', [
'server_resource_id' => $server->id,
'metadata' => ['username' => $binding->username],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]);
}
#[Apidoc\Title('服务器用户管理元数据'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}/system-users/meta')]
public function meta(int $id): JsonResponse
{
$server = $this->server($id);
$users = $this->client->users($server);
$groups = $this->client->groups($server);
$userGroups = [];
foreach ($users as $user) {
$username = (string) ($user['username'] ?? '');
if ($username === '') {
continue;
}
try {
$userGroups[$username] = $this->client->userGroups($server, $username)['groups'] ?? [];
} catch (ValidationException) {
$userGroups[$username] = [];
}
}
$bindings = ServerUserBinding::query()
->with('user:id,nickname,email,phone')
->where('server_resource_id', $server->id)
->orderBy('username')
->get();
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => [
'server' => $server,
'users' => $users,
'groups' => $groups,
'user_groups' => $userGroups,
'bindings' => $bindings,
],
]);
}
#[Apidoc\Title('创建服务器用户'), Apidoc\Method('POST'), Apidoc\Url('/servers/{id}/system-users')]
public function storeUser(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'password_hash' => ['nullable', 'string', 'min:10', 'max:512'],
'password' => ['nullable', 'string', 'min:6', 'max:255'],
'primary_group' => ['nullable', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'groups' => ['sometimes', 'array'],
'groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'user_id' => ['nullable', 'integer', 'exists:users,id'],
]);
$server = $this->server($id);
$passwordHash = (string) ($validated['password_hash'] ?? '');
if ($passwordHash === '') {
$passwordHash = $this->linuxPasswordHash((string) ($validated['password'] ?? ''));
}
$payload = [
'username' => $validated['username'],
'password_hash' => $passwordHash,
'primary_group' => $validated['primary_group'] ?? null,
'groups' => array_values(array_unique($validated['groups'] ?? [])),
];
$result = $this->client->createUser($server, $payload);
$this->upsertBinding((int) ($validated['user_id'] ?? 0), $server, (string) $validated['username'], true);
$this->auditLog($request, 'server_system_user_create', [
'server_resource_id' => $server->id,
'metadata' => ['username' => $validated['username']],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result], 201);
}
#[Apidoc\Title('删除服务器用户'), Apidoc\Method('DELETE'), Apidoc\Url('/servers/{id}/system-users/{username}')]
public function destroyUser(Request $request, int $id, string $username): JsonResponse
{
$server = $this->server($id);
$result = $this->client->deleteUser($server, $username);
ServerUserBinding::query()
->where('server_resource_id', $server->id)
->where('username', $username)
->update(['remote_exists' => false, 'last_synced_at' => now()]);
$this->auditLog($request, 'server_system_user_delete', [
'server_resource_id' => $server->id,
'metadata' => ['username' => $username],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]);
}
#[Apidoc\Title('修改服务器用户密码'), Apidoc\Method('PATCH'), Apidoc\Url('/servers/{id}/system-users/{username}/password')]
public function updatePassword(Request $request, int $id, string $username): JsonResponse
{
$validated = $request->validate([
'password_hash' => ['nullable', 'string', 'min:10', 'max:512'],
'password' => ['nullable', 'string', 'min:6', 'max:255'],
]);
$server = $this->server($id);
$passwordHash = (string) ($validated['password_hash'] ?? '');
if ($passwordHash === '') {
$passwordHash = $this->linuxPasswordHash((string) ($validated['password'] ?? ''));
}
$result = $this->client->updatePassword($server, $username, $passwordHash);
$this->auditLog($request, 'server_system_user_password_update', [
'server_resource_id' => $server->id,
'metadata' => ['username' => $username],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]);
}
#[Apidoc\Title('创建服务器用户组'), Apidoc\Method('POST'), Apidoc\Url('/servers/{id}/system-groups')]
public function storeGroup(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'groupname' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
]);
$server = $this->server($id);
$result = $this->client->createGroup($server, (string) $validated['groupname']);
$this->auditLog($request, 'server_system_group_create', [
'server_resource_id' => $server->id,
'metadata' => ['groupname' => $validated['groupname']],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result], 201);
}
#[Apidoc\Title('删除服务器用户组'), Apidoc\Method('DELETE'), Apidoc\Url('/servers/{id}/system-groups/{groupname}')]
public function destroyGroup(Request $request, int $id, string $groupname): JsonResponse
{
$server = $this->server($id);
$result = $this->client->deleteGroup($server, $groupname);
$this->auditLog($request, 'server_system_group_delete', [
'server_resource_id' => $server->id,
'metadata' => ['groupname' => $groupname],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]);
}
#[Apidoc\Title('同步服务器用户组'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}/system-users/{username}/groups')]
public function syncUserGroups(Request $request, int $id, string $username): JsonResponse
{
$validated = $request->validate([
'groups' => ['present', 'array'],
'groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'mode' => ['nullable', 'string', 'in:append,replace'],
]);
$server = $this->server($id);
$result = $this->client->syncUserGroups(
$server,
$username,
array_values(array_unique($validated['groups'])),
(string) ($validated['mode'] ?? 'replace'),
);
$this->auditLog($request, 'server_system_user_groups_update', [
'server_resource_id' => $server->id,
'metadata' => ['username' => $username, 'groups' => $validated['groups']],
]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]);
}
private function server(int $id): ServerResource
{
$server = ServerResource::query()->with('parent')->findOrFail($id);
return $server->parent_id ? $server->parent()->firstOrFail() : $server;
}
private function upsertBinding(int $userId, ServerResource $server, string $username, bool $remoteExists): void
{
if ($userId <= 0) {
return;
}
ServerUserBinding::query()->updateOrCreate(
['user_id' => $userId, 'server_resource_id' => $server->id],
['username' => $username, 'remote_exists' => $remoteExists, 'last_synced_at' => now()],
);
}
private function canUpdateBoundPassword(User $user, ServerResource $resource): bool
{
if ($user->can('platform.servers.view')) {
return true;
}
$serverId = (int) ($resource->parent_id ?: $resource->id);
$hasBinding = ServerUserBinding::query()
->where('user_id', $user->id)
->where('server_resource_id', $serverId)
->exists();
if (! $hasBinding) {
return false;
}
if (! $resource->parent_id) {
return true;
}
$pivot = $resource->users()
->where('users.id', $user->id)
->first()?->pivot;
if ($pivot && (bool) ($pivot->can_ssh || $pivot->can_sftp || $pivot->can_rdp)) {
return true;
}
return $user->can('resource.servers.use');
}
private function linuxPasswordHash(string $password): string
{
if ($password === '') {
throw ValidationException::withMessages([
'password' => ['请输入服务器账号密码。'],
]);
}
$salt = substr(strtr(base64_encode(random_bytes(12)), '+', '.'), 0, 16);
$hash = crypt($password, '$6$rounds=5000$'.$salt.'$');
if (! is_string($hash) || strlen($hash) < 10) {
throw ValidationException::withMessages([
'password' => ['服务器账号密码 Hash 生成失败。'],
]);
}
return $hash;
}
}

View File

@ -6,8 +6,10 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Models\ServerResource;
use App\Models\ServerUserBinding;
use App\Models\User;
use App\Models\UserServerPermission;
use App\Services\ServerUserManagementClient;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -20,11 +22,11 @@ use Spatie\Permission\Models\Role;
#[Apidoc\Title('用户管理')]
class UserController extends Controller
{
public function __construct()
public function __construct(private ServerUserManagementClient $serverUserClient)
{
$this->middleware('auth:api');
$this->middleware('permission:platform.users.view,api')->only(['index', 'show']);
$this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'syncBatchAssignments', 'import', 'importTemplate']);
$this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'syncBatchAssignments', 'syncServerBindings', 'import', 'importTemplate']);
}
#[Apidoc\Title('用户列表'), Apidoc\Method('GET'), Apidoc\Url('/users')]
@ -41,7 +43,7 @@ class UserController extends Controller
$perPage = (int) ($validated['per_page'] ?? 20);
$users = User::query()
->with('roles')
->with(['roles', 'serverUserBindings.server'])
->orderBy($sortBy, $sortOrder)
->paginate($perPage);
@ -51,21 +53,26 @@ class UserController extends Controller
#[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')]
public function store(StoreUserRequest $request): JsonResponse
{
$user = User::query()->create($request->validated());
$data = $request->safe()->except(['role_ids', 'server_bindings']);
$user = User::query()->create($data);
if ($request->filled('role_ids')) {
$user->syncRoles($request->validated('role_ids'));
}
if ($request->filled('server_bindings')) {
$this->syncServerBindingsPayload($user, $request->validated('server_bindings'), (string) $request->validated('password'));
}
$this->auditLog($request, 'user_create', ['metadata' => ['target_user_id' => $user->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')], 201);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])], 201);
}
#[Apidoc\Title('用户详情'), Apidoc\Method('GET'), Apidoc\Url('/users/{id}')]
public function show(int $id): JsonResponse
{
$user = User::query()->with(['roles', 'permissions', 'serverResources'])->findOrFail($id);
$user = User::query()->with(['roles', 'permissions', 'serverResources', 'serverUserBindings.server'])->findOrFail($id);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user]);
}
@ -78,7 +85,7 @@ class UserController extends Controller
}
$user = User::query()->findOrFail($id);
$user->fill($request->safe()->except(['role_ids']));
$user->fill($request->safe()->except(['role_ids', 'server_bindings']));
if ($request->filled('password')) {
$user->password = $request->validated('password');
@ -90,9 +97,33 @@ class UserController extends Controller
$user->syncRoles($request->validated('role_ids'));
}
if ($request->has('server_bindings')) {
$this->syncServerBindingsPayload($user, $request->validated('server_bindings'), (string) ($request->validated('password') ?? ''));
}
$this->auditLog($request, 'user_update', ['metadata' => ['target_user_id' => $user->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])]);
}
#[Apidoc\Title('同步用户服务器账号绑定'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/server-bindings')]
public function syncServerBindings(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'server_bindings' => ['present', 'array'],
'server_bindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'],
'server_bindings.*.username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'server_bindings.*.create_remote' => ['sometimes', 'boolean'],
'server_bindings.*.password' => ['nullable', 'string', 'min:6', 'max:255'],
'server_bindings.*.groups' => ['sometimes', 'array'],
'server_bindings.*.groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
]);
$user = User::query()->findOrFail($id);
$this->syncServerBindingsPayload($user, $validated['server_bindings'], '');
$this->auditLog($request, 'user_server_bindings_update', ['metadata' => ['target_user_id' => $user->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])]);
}
#[Apidoc\Title('同步用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/permissions')]
@ -157,6 +188,7 @@ class UserController extends Controller
{
$user = User::query()->findOrFail($id);
$this->auditLog($request, 'user_delete', ['metadata' => ['target_user_id' => $user->id]]);
ServerUserBinding::query()->where('user_id', $user->id)->delete();
$user->delete();
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
@ -502,6 +534,75 @@ class UserController extends Controller
$zip->close();
}
private function syncServerBindingsPayload(User $user, array $bindings, string $defaultPassword): void
{
$seenServerIds = [];
foreach ($bindings as $binding) {
$server = ServerResource::query()->with('parent')->findOrFail((int) $binding['server_resource_id']);
$targetServer = $server->parent_id ? $server->parent()->firstOrFail() : $server;
$serverId = (int) $targetServer->id;
$username = (string) $binding['username'];
$seenServerIds[] = $serverId;
$existingBinding = ServerUserBinding::query()
->where('user_id', $user->id)
->where('server_resource_id', $serverId)
->first();
$remoteExists = (bool) ($existingBinding?->remote_exists ?? false);
if ((bool) ($binding['create_remote'] ?? false)) {
$password = (string) ($binding['password'] ?? $defaultPassword);
$this->serverUserClient->createUser($targetServer, [
'username' => $username,
'password_hash' => $this->linuxPasswordHash($password),
'primary_group' => null,
'groups' => array_values(array_unique($binding['groups'] ?? [])),
]);
$remoteExists = true;
}
ServerUserBinding::query()->updateOrCreate(
[
'user_id' => $user->id,
'server_resource_id' => $serverId,
],
[
'username' => $username,
'remote_exists' => $remoteExists,
'last_synced_at' => $remoteExists ? now() : null,
'metadata' => [
'groups' => array_values(array_unique($binding['groups'] ?? [])),
],
],
);
}
ServerUserBinding::query()
->where('user_id', $user->id)
->when(! empty($seenServerIds), fn ($query) => $query->whereNotIn('server_resource_id', array_unique($seenServerIds)))
->when(empty($seenServerIds), fn ($query) => $query)
->delete();
}
private function linuxPasswordHash(string $password): string
{
if ($password === '') {
throw ValidationException::withMessages([
'password' => ['请输入服务器账号密码。'],
]);
}
$salt = substr(strtr(base64_encode(random_bytes(12)), '+', '.'), 0, 16);
$hash = crypt($password, '$6$rounds=5000$'.$salt.'$');
if (! is_string($hash) || strlen($hash) < 10) {
throw ValidationException::withMessages([
'password' => ['服务器账号密码 Hash 生成失败。'],
]);
}
return $hash;
}
private function syncServerResourcePermissionsByDirectPermissions(User $user, array $permissionIds): void
{
$resourcePermissions = Permission::query()

View File

@ -18,6 +18,8 @@ class StoreServerResourceRequest extends FormRequest
'display_name' => ['nullable', 'string', 'max:255'],
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
'internal_ip' => ['nullable', 'ip'],
'user_api_base_url' => ['nullable', 'url', 'max:255'],
'user_api_token' => ['nullable', 'string', 'max:2000'],
'asset_id' => ['nullable', 'integer', 'min:1'],
'account_id' => ['nullable', 'integer', 'min:1'],
'protocol' => ['nullable', 'string', 'max:64'],

View File

@ -21,6 +21,13 @@ class StoreUserRequest extends FormRequest
'force_password_change' => ['sometimes', 'boolean'],
'role_ids' => ['sometimes', 'array'],
'role_ids.*' => ['integer', 'exists:roles,id'],
'server_bindings' => ['sometimes', 'array'],
'server_bindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'],
'server_bindings.*.username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'server_bindings.*.create_remote' => ['sometimes', 'boolean'],
'server_bindings.*.password' => ['nullable', 'string', 'min:6', 'max:255'],
'server_bindings.*.groups' => ['sometimes', 'array'],
'server_bindings.*.groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
];
}
}

View File

@ -18,6 +18,8 @@ class UpdateServerResourceRequest extends FormRequest
'display_name' => ['nullable', 'string', 'max:255'],
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
'internal_ip' => ['nullable', 'ip'],
'user_api_base_url' => ['nullable', 'url', 'max:255'],
'user_api_token' => ['nullable', 'string', 'max:2000'],
'asset_id' => ['nullable', 'integer', 'min:1'],
'account_id' => ['nullable', 'integer', 'min:1'],
'protocol' => ['nullable', 'string', 'max:64'],

View File

@ -24,6 +24,13 @@ class UpdateUserRequest extends FormRequest
'force_password_change' => ['sometimes', 'boolean'],
'role_ids' => ['sometimes', 'array'],
'role_ids.*' => ['integer', 'exists:roles,id'],
'server_bindings' => ['sometimes', 'array'],
'server_bindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'],
'server_bindings.*.username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
'server_bindings.*.create_remote' => ['sometimes', 'boolean'],
'server_bindings.*.password' => ['nullable', 'string', 'min:6', 'max:255'],
'server_bindings.*.groups' => ['sometimes', 'array'],
'server_bindings.*.groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'],
];
}
}

View File

@ -17,6 +17,8 @@ class ServerResource extends Model
'display_name',
'parent_id',
'internal_ip',
'user_api_base_url',
'user_api_token',
'asset_id',
'account_id',
'protocols',
@ -25,6 +27,10 @@ class ServerResource extends Model
'is_active',
];
protected $hidden = [
'user_api_token',
];
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
@ -42,6 +48,11 @@ class ServerResource extends Model
->withTimestamps();
}
public function serverUserBindings(): HasMany
{
return $this->hasMany(ServerUserBinding::class, 'server_resource_id');
}
public function accessLogs(): HasMany
{
return $this->hasMany(AccessLog::class);
@ -51,6 +62,7 @@ class ServerResource extends Model
{
return [
'protocols' => 'array',
'user_api_token' => 'encrypted',
'allow_copy_temp_password' => 'boolean',
'is_active' => 'boolean',
];

View File

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ServerUserBinding extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'server_resource_id',
'username',
'remote_exists',
'last_synced_at',
'metadata',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(ServerResource::class, 'server_resource_id');
}
protected function casts(): array
{
return [
'remote_exists' => 'boolean',
'last_synced_at' => 'datetime',
'metadata' => 'array',
];
}
}

View File

@ -48,6 +48,11 @@ class User extends Authenticatable implements JWTSubject
->withTimestamps();
}
public function serverUserBindings(): HasMany
{
return $this->hasMany(ServerUserBinding::class);
}
public function opsSoftwarePreferences(): HasMany
{
return $this->hasMany(UserOpsSoftwarePreference::class);

View File

@ -0,0 +1,139 @@
<?php
namespace App\Services;
use App\Models\ServerResource;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class ServerUserManagementClient
{
public function users(ServerResource $server): array
{
return $this->request($server, 'get', '/users')->json();
}
public function user(ServerResource $server, string $username): array
{
return $this->request($server, 'get', '/users/'.$this->encodePath($username))->json();
}
public function createUser(ServerResource $server, array $payload): array
{
return $this->request($server, 'post', '/users', $payload)->json();
}
public function deleteUser(ServerResource $server, string $username): array
{
return $this->request($server, 'delete', '/users/'.$this->encodePath($username))->json();
}
public function updatePassword(ServerResource $server, string $username, string $passwordHash): array
{
return $this->request($server, 'patch', '/users/'.$this->encodePath($username).'/password', [
'password_hash' => $passwordHash,
])->json();
}
public function groups(ServerResource $server): array
{
return $this->request($server, 'get', '/groups')->json();
}
public function createGroup(ServerResource $server, string $groupname): array
{
return $this->request($server, 'post', '/groups', ['groupname' => $groupname])->json();
}
public function deleteGroup(ServerResource $server, string $groupname): array
{
return $this->request($server, 'delete', '/groups/'.$this->encodePath($groupname))->json();
}
public function userGroups(ServerResource $server, string $username): array
{
return $this->request($server, 'get', '/users/'.$this->encodePath($username).'/groups')->json();
}
public function syncUserGroups(ServerResource $server, string $username, array $groups, string $mode = 'replace'): array
{
return $this->request($server, 'post', '/users/'.$this->encodePath($username).'/groups', [
'groups' => array_values($groups),
'mode' => $mode,
])->json();
}
public function removeUserGroups(ServerResource $server, string $username, array $groups): array
{
return $this->request($server, 'delete', '/users/'.$this->encodePath($username).'/groups', [
'groups' => array_values($groups),
'mode' => 'append',
])->json();
}
private function request(ServerResource $server, string $method, string $path, array $payload = [])
{
$target = $this->resolveServer($server);
$baseUrl = rtrim((string) $target->user_api_base_url, '/');
$token = trim((string) $target->user_api_token);
if ($baseUrl === '' || $token === '') {
throw ValidationException::withMessages([
'server' => ['该服务器未配置用户管理 API 地址或密钥。'],
]);
}
try {
$pending = Http::baseUrl($baseUrl)
->acceptJson()
->asJson()
->timeout((int) config('services.server_user_management.timeout', 15))
->connectTimeout((int) config('services.server_user_management.connect_timeout', 5))
->retry(2, 200, throw: false)
->withToken($token)
->withOptions([
'verify' => (bool) config('services.server_user_management.verify_ssl', false),
]);
$response = match ($method) {
'post' => $pending->post($path, $payload),
'patch' => $pending->patch($path, $payload),
'delete' => empty($payload) ? $pending->delete($path) : $pending->send('DELETE', $path, ['json' => $payload]),
default => $pending->get($path),
};
} catch (ConnectionException|RequestException $exception) {
throw ValidationException::withMessages([
'server' => ['服务器用户管理 API 调用失败:'.$exception->getMessage()],
]);
}
if (! $response->successful()) {
$message = (string) (data_get($response->json(), 'message')
?: data_get($response->json(), 'detail.message')
?: data_get($response->json(), 'detail')
?: '服务器用户管理 API 返回异常');
throw ValidationException::withMessages([
'server' => [$message],
]);
}
return $response;
}
private function resolveServer(ServerResource $server): ServerResource
{
if (! $server->parent_id) {
return $server;
}
return $server->parent()->firstOrFail();
}
private function encodePath(string $value): string
{
return rawurlencode($value);
}
}

View File

@ -64,6 +64,8 @@ return Application::configure(basePath: dirname(__DIR__))
'guard_name' => '守卫',
'parent_id' => '所属服务器',
'internal_ip' => '内网IP',
'user_api_base_url' => '用户管理API地址',
'user_api_token' => '用户管理API密钥',
'asset_id' => '资产ID',
'account_id' => '账号ID',
'protocol' => '协议',
@ -87,6 +89,11 @@ return Application::configure(basePath: dirname(__DIR__))
'per_page' => '每页数量',
'username' => '用户名',
'token' => '令牌',
'server_bindings' => '服务器账号绑定',
'server_resource_id' => '服务器',
'password_hash' => '服务器账号密码',
'groups' => '用户组',
'groupname' => '用户组',
];
$resolveAttribute = function (string $field) use ($attributeLabels): string {

View File

@ -55,6 +55,12 @@ return [
],
],
'server_user_management' => [
'timeout' => (int) env('SERVER_USER_MANAGEMENT_TIMEOUT', 15),
'connect_timeout' => (int) env('SERVER_USER_MANAGEMENT_CONNECT_TIMEOUT', 5),
'verify_ssl' => (bool) env('SERVER_USER_MANAGEMENT_VERIFY_SSL', false),
],
'ops_client' => [
'ipv4' => env('OPS_CLIENT_IPV4', '172.16.1.2'),
'asset_ipv4' => env('OPS_CLIENT_ASSET_IPV4', '0.0.0.0'),

View File

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('server_resources', function (Blueprint $table) {
$table->string('user_api_base_url')->nullable()->after('internal_ip');
$table->text('user_api_token')->nullable()->after('user_api_base_url');
});
}
public function down(): void
{
Schema::table('server_resources', function (Blueprint $table) {
$table->dropColumn(['user_api_base_url', 'user_api_token']);
});
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('server_user_bindings', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('server_resource_id')->constrained('server_resources')->cascadeOnDelete();
$table->string('username');
$table->boolean('remote_exists')->default(false);
$table->timestamp('last_synced_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->unique(['user_id', 'server_resource_id']);
$table->index(['server_resource_id', 'username']);
});
}
public function down(): void
{
Schema::dropIfExists('server_user_bindings');
}
};

View File

@ -0,0 +1,167 @@
<?php
namespace Tests\Feature;
use App\Models\ServerResource;
use App\Models\ServerUserBinding;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class ServerSystemUserManagementTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_read_server_system_user_meta(): void
{
Http::preventStrayRequests();
Http::fake([
'http://node.test/users' => Http::response([
['username' => 'alice', 'uid' => 1000, 'gid' => 1000, 'home_dir' => '/home/alice', 'shell' => '/bin/bash'],
]),
'http://node.test/groups' => Http::response([
['groupname' => 'dev', 'gid' => 1000, 'members' => ['alice']],
]),
'http://node.test/users/alice/groups' => Http::response(['username' => 'alice', 'groups' => ['dev']]),
]);
$admin = $this->admin();
$server = $this->server();
ServerUserBinding::query()->create([
'user_id' => $admin->id,
'server_resource_id' => $server->id,
'username' => 'alice',
'remote_exists' => true,
]);
$response = $this->actingAs($admin, 'api')->getJson('/servers/'.$server->id.'/system-users/meta');
$response
->assertOk()
->assertJsonPath('code', 0)
->assertJsonPath('data.users.0.username', 'alice')
->assertJsonPath('data.groups.0.groupname', 'dev')
->assertJsonPath('data.user_groups.alice.0', 'dev');
}
public function test_creating_user_can_create_remote_server_account_and_binding(): void
{
Http::preventStrayRequests();
Http::fake([
'http://node.test/users' => Http::response(['status' => 'ok', 'message' => 'User created.'], 201),
]);
$admin = $this->admin();
$server = $this->server();
$response = $this->actingAs($admin, 'api')->postJson('/users', [
'nickname' => 'Alice',
'email' => 'alice@example.com',
'phone' => '13800138000',
'password' => 'secret123',
'server_bindings' => [[
'server_resource_id' => $server->id,
'username' => 'alice',
'create_remote' => true,
'groups' => [],
]],
]);
$response->assertCreated()->assertJsonPath('code', 0);
$user = User::query()->where('email', 'alice@example.com')->firstOrFail();
$this->assertDatabaseHas('server_user_bindings', [
'user_id' => $user->id,
'server_resource_id' => $server->id,
'username' => 'alice',
'remote_exists' => true,
]);
Http::assertSent(function (Request $request): bool {
return $request->method() === 'POST'
&& $request->url() === 'http://node.test/users'
&& $request['username'] === 'alice'
&& is_string($request['password_hash'])
&& str_starts_with($request['password_hash'], '$6$');
});
}
public function test_deleting_sso_user_does_not_delete_remote_server_user(): void
{
Http::fake();
$admin = $this->admin();
$server = $this->server();
$target = User::factory()->create();
ServerUserBinding::query()->create([
'user_id' => $target->id,
'server_resource_id' => $server->id,
'username' => 'target',
'remote_exists' => true,
]);
$response = $this->actingAs($admin, 'api')->deleteJson('/users/'.$target->id);
$response->assertOk()->assertJsonPath('code', 0);
$this->assertDatabaseMissing('server_user_bindings', ['user_id' => $target->id]);
Http::assertNothingSent();
}
public function test_server_list_includes_bound_username_without_exposing_token(): void
{
$admin = $this->admin();
$server = $this->server();
$resource = ServerResource::query()->create([
'parent_id' => $server->id,
'name' => 'ssh',
'display_name' => 'SSH',
'internal_ip' => '10.0.0.10',
'asset_id' => 1,
'account_id' => 2,
'protocols' => ['SSH'],
'is_active' => true,
]);
ServerUserBinding::query()->create([
'user_id' => $admin->id,
'server_resource_id' => $server->id,
'username' => 'admin',
'remote_exists' => true,
]);
$response = $this->actingAs($admin, 'api')->getJson('/servers');
$response
->assertOk()
->assertJsonPath('data.data.0.user_api_configured', true)
->assertJsonMissingPath('data.data.0.user_api_token');
$resourcePayload = collect($response->json('data.data'))->firstWhere('id', $resource->id);
$this->assertSame('admin', $resourcePayload['server_username'] ?? null);
}
private function admin(): User
{
$user = User::factory()->create();
Role::query()->firstOrCreate(['name' => 'admin', 'guard_name' => 'api']);
$user->assignRole('admin');
return $user;
}
private function server(): ServerResource
{
return ServerResource::query()->create([
'name' => 'server01',
'display_name' => 'Server 01',
'internal_ip' => '10.0.0.10',
'user_api_base_url' => 'http://node.test',
'user_api_token' => 'secret-token',
'asset_id' => 1,
'account_id' => null,
'protocols' => [],
'is_active' => true,
]);
}
}

View File

@ -0,0 +1,18 @@
TOKEN=replace-me
SERVER_NAME=user-manage-api
HOME_BASE_DIR=/home
LINK_HOME_DIR=
DEFAULT_SHELL=/bin/bash
WHITELIST_USERS=
WHITELIST_GROUPS=
LOCKED_USERS=
HIDDEN_USERS=root,daemon,nobody
HIDDEN_GROUPS=root,daemon,nogroup
USER_UID_MIN=1000
USER_UID_MAX=60000
GROUP_GID_MIN=1000
GROUP_GID_MAX=60000
USE_LIBUSER=false
LOG_LEVEL=INFO
LOG_PATH=./logs/user_manage_api.log
SUDO_PATH=/usr/bin/sudo

36
user_manage_api/README.md Normal file
View File

@ -0,0 +1,36 @@
# Ubuntu User Management API (FastAPI, V1)
## Run
```bash
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
## Required Env
- `TOKEN`
- `HOME_BASE_DIR` (default `/home`)
- `USE_LIBUSER` (default `false`)
- `LOG_LEVEL` (default `INFO`)
- `LOG_PATH` (default `./logs/user_manage_api.log`)
- `SUDO_PATH` (default `/usr/bin/sudo`)
## Notes
- API process should run as non-root.
- System account commands are executed through sudo allowlist.
- Password input is pre-hashed only (no plaintext transformation in service).
- Deleting user keeps home directory by default.
## Test
```bash
pytest -q
```
## Generate Python SDK
```bash
python scripts/generate_python_sdk.py
```

View File

@ -0,0 +1,588 @@
# Ubuntu User Management API
## 基本信息
- 服务地址示例: `http://127.0.0.1:8000`
- API 版本: `1.0.0`
- 文档地址: `/docs`
- OpenAPI 地址: `/openapi.json`
- 请求与响应格式: `application/json`
## 鉴权
所有业务接口都需要 Bearer Token。
请求头:
```http
Authorization: Bearer <TOKEN>
```
`TOKEN` 来自 `.env`:
```env
TOKEN=your-token
SERVER_NAME=user-manage-api
```
鉴权失败响应:
```json
{
"detail": {
"code": "unauthorized",
"message": "Invalid bearer token."
}
}
```
## 通用错误格式
业务错误:
```json
{
"code": "not_found",
"message": "user not found"
}
```
参数错误:
```json
{
"code": "invalid_parameter",
"message": "..."
}
```
常见错误码:
| HTTP 状态码 | code | 描述 |
| --- | --- | --- |
| 400 | `invalid_parameter` | 请求参数格式或校验失败 |
| 401 | `unauthorized` | 未提供 token 或 token 不正确 |
| 404 | `not_found` | 用户或用户组不存在,或被可见性规则隐藏 |
| 409 | `resource_conflict` | 用户或用户组已存在 |
| 422 | `precondition_failed` | 前置条件不满足,例如用户组仍有成员 |
| 423 | `user_locked` | 用户已锁定,只允许查看,不允许修改 |
| 500 | `system_command_error` | 系统命令执行失败 |
| 503 | `system_timeout` | 系统命令超时 |
| 503 | `system_permission_denied` | 系统命令权限不足 |
## 可见性规则
用户和用户组会受 `.env` 中的白名单、黑名单和 UID/GID 范围限制。
优先级:
```text
白名单 > 黑名单 > UID/GID 范围
```
相关配置:
```env
WHITELIST_USERS=
WHITELIST_GROUPS=
HIDDEN_USERS=root,daemon,nobody
HIDDEN_GROUPS=root,daemon,nogroup
LOCKED_USERS=
USER_UID_MIN=1000
USER_UID_MAX=60000
GROUP_GID_MIN=1000
GROUP_GID_MAX=60000
```
说明:
- 白名单中的用户/用户组始终可见并允许操作。
- 黑名单中的用户/用户组会被隐藏并禁止操作,除非同时在白名单中。
- 不在白名单和黑名单时,用户按 UID 范围判断,用户组按 GID 范围判断。
- 被隐藏的用户或用户组对 API 表现为 `404 not_found`
- `LOCKED_USERS` 中的用户可以查看,但不能创建同名用户、删除、改密码、添加用户组或移除用户组。
## Home 目录规则
相关配置:
```env
HOME_BASE_DIR=/home
LINK_HOME_DIR=
DEFAULT_SHELL=/bin/bash
```
创建用户时:
- API 不接收客户端传入的 `home_dir``shell`
- 用户 home 固定使用 `HOME_BASE_DIR/username`
- 登录 shell 固定使用 `DEFAULT_SHELL`
- `LINK_HOME_DIR` 为空时,用户目录直接创建在 `HOME_BASE_DIR` 下。
- `LINK_HOME_DIR` 不为空时,实际目录创建在 `LINK_HOME_DIR/username`,并在 `HOME_BASE_DIR/username` 创建软链接。
删除用户时:
- 会删除用户账号。
- 如果用户 home 是软链接,则删除该软链接。
- 如果用户 home 是普通目录,不会删除目录内容。
## 数据模型
### UserCreateRequest
| 字段 | 类型 | 必填 | 默认值 | 描述 |
| --- | --- | --- | --- | --- |
| `username` | string | 是 | - | 用户名。格式: `^[a-z_][a-z0-9_-]{0,31}$` |
| `password_hash` | string | 是 | - | 预先生成的 Linux 密码 hash长度 10 到 512 |
| `primary_group` | string/null | 否 | `null` | 主用户组。格式同用户组名 |
| `groups` | string[] | 否 | `[]` | 附加用户组,会自动去重 |
### UserSummary
| 字段 | 类型 | 描述 |
| --- | --- | --- |
| `username` | string | 用户名 |
| `uid` | integer | 用户 UID |
| `gid` | integer | 用户主 GID |
| `home_dir` | string | 用户 home 路径 |
| `shell` | string | 登录 shell |
### GroupCreateRequest
| 字段 | 类型 | 必填 | 描述 |
| --- | --- | --- | --- |
| `groupname` | string | 是 | 用户组名。格式: `^[a-z_][a-z0-9_-]{0,31}$` |
### GroupSummary
| 字段 | 类型 | 描述 |
| --- | --- | --- |
| `groupname` | string | 用户组名 |
| `gid` | integer | 用户组 GID |
| `members` | string[] | 用户组成员 |
### UserGroupsUpdateRequest
| 字段 | 类型 | 必填 | 默认值 | 描述 |
| --- | --- | --- | --- | --- |
| `groups` | string[] | 是 | - | 用户组列表,至少 1 个,会自动去重 |
| `mode` | string | 否 | `append` | `append` 表示追加,`replace` 表示替换全部附加组 |
### UserPasswordUpdateRequest
| 字段 | 类型 | 必填 | 描述 |
| --- | --- | --- | --- |
| `password_hash` | string | 是 | 新密码的 Linux hash长度 10 到 512。API 不接收明文密码 |
### ApiResponse
| 字段 | 类型 | 描述 |
| --- | --- | --- |
| `status` | string | 固定为 `ok` |
| `message` | string | 操作结果描述 |
## API 列表
### 健康检查
```http
GET /health
```
描述:
返回服务器名称和在线状态。服务器名称来自 `.env` 中的 `SERVER_NAME`
成功响应:
```json
{
"server_name": "user-manage-api",
"status": "online"
}
```
字段说明:
| 字段 | 类型 | 描述 |
| --- | --- | --- |
| `server_name` | string | 服务器名称,由 `SERVER_NAME` 配置 |
| `status` | string | 固定为 `online` |
### 创建用户
```http
POST /users
```
描述:
创建系统用户。密码必须由调用方提前生成 hashAPI 不处理明文密码。
请求体:
```json
{
"username": "alice",
"password_hash": "$6$salt$hash",
"primary_group": null,
"groups": ["dev"]
}
```
最小请求体:
```json
{
"username": "alice",
"password_hash": "$6$salt$hash"
}
```
成功响应:
```json
{
"status": "ok",
"message": "User created."
}
```
注意:
- `username` 在黑名单中且不在白名单中时禁止创建。
- `primary_group``groups` 中的用户组必须存在且可见。
- 请求体不允许传入 `shell``home_dir`,两者由服务端配置决定。
- 启用 `LINK_HOME_DIR` 后,账号 home 仍为 `HOME_BASE_DIR/username`,实际目录位于 `LINK_HOME_DIR/username`
### 删除用户
```http
DELETE /users/{username}
```
路径参数:
| 参数 | 类型 | 描述 |
| --- | --- | --- |
| `username` | string | 要删除的用户名 |
成功响应:
```json
{
"status": "ok",
"message": "User deleted."
}
```
注意:
- 用户不存在或被隐藏时返回 `404`
- 用户在 `LOCKED_USERS` 中时返回 `423 user_locked`
- 删除账号后,如果 home 是软链接,会删除该软链接。
- 如果 home 是普通目录,不会删除目录内容。
### 修改用户密码
```http
PATCH /users/{username}/password
```
路径参数:
| 参数 | 类型 | 描述 |
| --- | --- | --- |
| `username` | string | 要修改密码的用户名 |
请求体:
```json
{
"password_hash": "$6$rounds=5000$salt$hash"
}
```
成功响应:
```json
{
"status": "ok",
"message": "User password updated."
}
```
注意:
- `password_hash` 必须是调用方提前生成的 Linux 密码 hash。
- 用户不存在或被隐藏时返回 `404`
- 用户在 `LOCKED_USERS` 中时返回 `423 user_locked`
### 获取用户列表
```http
GET /users
```
描述:
返回可见用户列表。会应用白名单、黑名单和 UID 范围规则。
成功响应:
```json
[
{
"username": "alice",
"uid": 1000,
"gid": 1000,
"home_dir": "/home/alice",
"shell": "/bin/bash"
}
]
```
### 获取用户详情
```http
GET /users/{username}
```
路径参数:
| 参数 | 类型 | 描述 |
| --- | --- | --- |
| `username` | string | 用户名 |
成功响应:
```json
{
"username": "alice",
"uid": 1000,
"gid": 1000,
"home_dir": "/home/alice",
"shell": "/bin/bash"
}
```
### 创建用户组
```http
POST /groups
```
请求体:
```json
{
"groupname": "dev"
}
```
成功响应:
```json
{
"status": "ok",
"message": "Group created."
}
```
注意:
- `groupname` 在黑名单中且不在白名单中时禁止创建。
### 删除用户组
```http
DELETE /groups/{groupname}
```
路径参数:
| 参数 | 类型 | 描述 |
| --- | --- | --- |
| `groupname` | string | 要删除的用户组名 |
成功响应:
```json
{
"status": "ok",
"message": "Group deleted."
}
```
注意:
- 用户组不存在或被隐藏时返回 `404`
- 用户组仍有成员时返回 `422 precondition_failed`
### 获取用户组列表
```http
GET /groups
```
描述:
返回可见用户组列表。会应用白名单、黑名单和 GID 范围规则。
成功响应:
```json
[
{
"groupname": "dev",
"gid": 1000,
"members": ["alice"]
}
]
```
### 获取用户组详情
```http
GET /groups/{groupname}
```
路径参数:
| 参数 | 类型 | 描述 |
| --- | --- | --- |
| `groupname` | string | 用户组名 |
成功响应:
```json
{
"groupname": "dev",
"gid": 1000,
"members": ["alice"]
}
```
### 添加或替换用户附加组
```http
POST /users/{username}/groups
```
路径参数:
| 参数 | 类型 | 描述 |
| --- | --- | --- |
| `username` | string | 用户名 |
请求体:
```json
{
"groups": ["dev", "ops"],
"mode": "append"
}
```
参数说明:
| 字段 | 类型 | 描述 |
| --- | --- | --- |
| `groups` | string[] | 要添加或替换的用户组列表 |
| `mode` | string | `append` 追加用户组,`replace` 替换全部附加组 |
成功响应:
```json
{
"status": "ok",
"message": "User groups updated."
}
```
注意:
- 用户不存在或被隐藏时返回 `404`
- 用户在 `LOCKED_USERS` 中时返回 `423 user_locked`
- 目标用户组不存在或被隐藏时返回 `404`
### 移除用户附加组
```http
DELETE /users/{username}/groups
```
路径参数:
| 参数 | 类型 | 描述 |
| --- | --- | --- |
| `username` | string | 用户名 |
请求体:
```json
{
"groups": ["dev"],
"mode": "append"
}
```
说明:
`mode` 字段会被接收,但删除操作只使用 `groups`
成功响应:
```json
{
"status": "ok",
"message": "User groups removed."
}
```
注意:
- 用户不存在或被隐藏时返回 `404`
- 用户在 `LOCKED_USERS` 中时返回 `423 user_locked`
### 获取用户所属用户组
```http
GET /users/{username}/groups
```
路径参数:
| 参数 | 类型 | 描述 |
| --- | --- | --- |
| `username` | string | 用户名 |
成功响应:
```json
{
"username": "alice",
"groups": ["dev", "ops"]
}
```
说明:
返回的 `groups` 会过滤掉不可见用户组。
## curl 示例
```bash
curl -X GET 'http://127.0.0.1:8000/users' \
-H 'Authorization: Bearer <TOKEN>'
```
```bash
curl -X POST 'http://127.0.0.1:8000/users' \
-H 'Authorization: Bearer <TOKEN>' \
-H 'Content-Type: application/json' \
-d '{
"username": "alice",
"password_hash": "$6$rounds=5000$salt$hash"
}'
```

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,23 @@
from typing import Dict, Optional
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import app.container as container
bearer_scheme = HTTPBearer(auto_error=False)
def auth_dependency(credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme)) -> None:
if credentials is None:
raise HTTPException(status_code=401, detail={"code": "unauthorized", "message": "Invalid auth header."})
if credentials.credentials != container.app_state.settings.token:
raise HTTPException(status_code=401, detail={"code": "unauthorized", "message": "Invalid bearer token."})
def caller_identity(request: Request) -> Dict[str, str]:
return {
"ip": request.client.host if request.client else "unknown",
"request_id": request.headers.get("x-request-id", ""),
}

View File

@ -0,0 +1,98 @@
import time
from typing import Dict, List
from fastapi import APIRouter, Depends, Request
import app.container as container
from app.api.deps import auth_dependency, caller_identity
from app.core.errors import ApiError
from app.core.models import ApiResponse, GroupCreateRequest, UserCreateRequest, UserGroupsUpdateRequest, UserPasswordUpdateRequest
router = APIRouter(dependencies=[Depends(auth_dependency)])
@router.get("/health")
def health() -> Dict:
return {"server_name": container.app_state.settings.server_name, "status": "online"}
@router.post("/users", response_model=ApiResponse)
def create_user(payload: UserCreateRequest, request: Request) -> ApiResponse:
identity = caller_identity(request)
started = time.perf_counter()
try:
container.app_state.service.create_user(payload)
container.app_state.audit.log(operation="create_user", target=payload.username, result="success", request_id=identity["request_id"], source_ip=identity["ip"])
return ApiResponse(message="User created.")
except ApiError as exc:
container.app_state.audit.log(operation="create_user", target=payload.username, result="failed", error_code=exc.code, request_id=identity["request_id"], source_ip=identity["ip"], elapsed_ms=int((time.perf_counter() - started) * 1000))
raise
@router.delete("/users/{username}", response_model=ApiResponse)
def delete_user(username: str, request: Request) -> ApiResponse:
identity = caller_identity(request)
container.app_state.service.delete_user(username)
container.app_state.audit.log(operation="delete_user", target=username, result="success", request_id=identity["request_id"], source_ip=identity["ip"])
return ApiResponse(message="User deleted.")
@router.patch("/users/{username}/password", response_model=ApiResponse)
def change_user_password(username: str, payload: UserPasswordUpdateRequest, request: Request) -> ApiResponse:
identity = caller_identity(request)
container.app_state.service.change_user_password(username=username, password_hash=payload.password_hash)
container.app_state.audit.log(operation="change_user_password", target=username, result="success", request_id=identity["request_id"], source_ip=identity["ip"])
return ApiResponse(message="User password updated.")
@router.get("/users")
def list_users() -> List[Dict]:
return [item.model_dump() for item in container.app_state.service.list_users()]
@router.get("/users/{username}")
def get_user(username: str) -> Dict:
return container.app_state.service.get_user(username).model_dump()
@router.post("/groups", response_model=ApiResponse)
def create_group(payload: GroupCreateRequest, request: Request) -> ApiResponse:
identity = caller_identity(request)
container.app_state.service.create_group(payload.groupname)
container.app_state.audit.log(operation="create_group", target=payload.groupname, result="success", request_id=identity["request_id"], source_ip=identity["ip"])
return ApiResponse(message="Group created.")
@router.delete("/groups/{groupname}", response_model=ApiResponse)
def delete_group(groupname: str, request: Request) -> ApiResponse:
identity = caller_identity(request)
container.app_state.service.delete_group(groupname)
container.app_state.audit.log(operation="delete_group", target=groupname, result="success", request_id=identity["request_id"], source_ip=identity["ip"])
return ApiResponse(message="Group deleted.")
@router.get("/groups")
def list_groups() -> List[Dict]:
return [item.model_dump() for item in container.app_state.service.list_groups()]
@router.get("/groups/{groupname}")
def get_group(groupname: str) -> Dict:
return container.app_state.service.get_group(groupname).model_dump()
@router.post("/users/{username}/groups", response_model=ApiResponse)
def add_user_groups(username: str, payload: UserGroupsUpdateRequest) -> ApiResponse:
container.app_state.service.add_user_groups(username=username, groups=payload.groups, replace=payload.mode == "replace")
return ApiResponse(message="User groups updated.")
@router.delete("/users/{username}/groups", response_model=ApiResponse)
def remove_user_groups(username: str, payload: UserGroupsUpdateRequest) -> ApiResponse:
container.app_state.service.remove_user_groups(username=username, groups=payload.groups)
return ApiResponse(message="User groups removed.")
@router.get("/users/{username}/groups")
def get_user_groups(username: str) -> Dict:
return {"username": username, "groups": container.app_state.service.get_user_groups(username)}

View File

@ -0,0 +1,3 @@
from app.state import AppState
app_state: AppState

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,49 @@
import json
import logging
import time
from pathlib import Path
from typing import Any, Dict
class AuditLogger:
def __init__(self, log_path: str):
self.log_path = Path(log_path)
self.log_path.parent.mkdir(parents=True, exist_ok=True)
self.json_log_path = self.log_path.with_suffix(".jsonl")
self.text_logger = logging.getLogger("audit_text")
self.text_logger.setLevel(logging.INFO)
if not self.text_logger.handlers:
handler = logging.FileHandler(self.log_path, encoding="utf-8")
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
handler.setFormatter(formatter)
self.text_logger.addHandler(handler)
@staticmethod
def sanitize(payload: Dict[str, Any]) -> Dict[str, Any]:
hidden_keys = {"token", "authorization", "password", "password_hash", "command"}
sanitized: Dict[str, Any] = {}
for key, value in payload.items():
if key.lower() in hidden_keys:
sanitized[key] = "***"
else:
sanitized[key] = value
return sanitized
def log(self, **kwargs: Any) -> None:
start = time.perf_counter()
record = self.sanitize(kwargs)
record["ts"] = time.time()
record["duration_ms"] = int((time.perf_counter() - start) * 1000)
self.text_logger.info(
"operation=%s target=%s result=%s code=%s request_id=%s",
record.get("operation"),
record.get("target"),
record.get("result"),
record.get("error_code"),
record.get("request_id"),
)
with self.json_log_path.open("a", encoding="utf-8") as file:
file.write(json.dumps(record, ensure_ascii=False) + "\n")

View File

@ -0,0 +1,91 @@
from pathlib import Path
import shutil
from typing import List, Optional
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
token: str = Field(alias="TOKEN")
server_name: str = Field(default="user-manage-api", alias="SERVER_NAME")
home_base_dir: str = Field(default="/home", alias="HOME_BASE_DIR")
link_home_dir: str = Field(default="", alias="LINK_HOME_DIR")
default_shell: str = Field(default="/bin/bash", alias="DEFAULT_SHELL")
whitelist_users: str = Field(default="", alias="WHITELIST_USERS")
whitelist_groups: str = Field(default="", alias="WHITELIST_GROUPS")
locked_users: str = Field(default="", alias="LOCKED_USERS")
hidden_users: str = Field(default="", alias="HIDDEN_USERS")
hidden_groups: str = Field(default="", alias="HIDDEN_GROUPS")
user_uid_min: Optional[int] = Field(default=None, alias="USER_UID_MIN")
user_uid_max: Optional[int] = Field(default=None, alias="USER_UID_MAX")
group_gid_min: Optional[int] = Field(default=None, alias="GROUP_GID_MIN")
group_gid_max: Optional[int] = Field(default=None, alias="GROUP_GID_MAX")
use_libuser: bool = Field(default=False, alias="USE_LIBUSER")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_path: str = Field(default="./logs/user_manage_api.log", alias="LOG_PATH")
sudo_path: str = Field(default="/usr/bin/sudo", alias="SUDO_PATH")
command_timeout_seconds: int = 10
@field_validator("user_uid_min", "user_uid_max", "group_gid_min", "group_gid_max", mode="before")
@classmethod
def empty_string_to_none(cls, value: object) -> object:
if value == "":
return None
return value
@property
def whitelist_user_list(self) -> List[str]:
return self._parse_comma_separated_list(self.whitelist_users)
@property
def whitelist_group_list(self) -> List[str]:
return self._parse_comma_separated_list(self.whitelist_groups)
@property
def locked_user_list(self) -> List[str]:
return self._parse_comma_separated_list(self.locked_users)
@property
def hidden_user_list(self) -> List[str]:
return self._parse_comma_separated_list(self.hidden_users)
@property
def hidden_group_list(self) -> List[str]:
return self._parse_comma_separated_list(self.hidden_groups)
def _parse_comma_separated_list(self, value: str) -> List[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def validate_settings(settings: Settings) -> None:
if not settings.token.strip():
raise ValueError("TOKEN is required and cannot be empty.")
log_parent = Path(settings.log_path).parent
log_parent.mkdir(parents=True, exist_ok=True)
if not log_parent.exists() or not log_parent.is_dir():
raise ValueError(f"LOG_PATH parent is invalid: {log_parent}")
if shutil.which(settings.sudo_path) is None and not Path(settings.sudo_path).exists():
raise ValueError(f"SUDO_PATH is not executable: {settings.sudo_path}")
if settings.user_uid_min is not None and settings.user_uid_max is not None and settings.user_uid_min > settings.user_uid_max:
raise ValueError("USER_UID_MIN cannot be greater than USER_UID_MAX.")
if settings.group_gid_min is not None and settings.group_gid_max is not None and settings.group_gid_min > settings.group_gid_max:
raise ValueError("GROUP_GID_MIN cannot be greater than GROUP_GID_MAX.")
if not settings.default_shell.startswith("/"):
raise ValueError("DEFAULT_SHELL must be an absolute path.")
required_commands = ["useradd", "usermod", "userdel", "groupadd", "groupdel", "chpasswd", "id", "getent"]
if settings.link_home_dir.strip():
required_commands.extend(["mkdir", "ln", "chown", "unlink"])
for command in required_commands:
if shutil.which(command) is None:
raise ValueError(f"Required command not found in PATH: {command}")

View File

@ -0,0 +1,22 @@
class ApiError(Exception):
def __init__(self, status_code: int, code: str, message: str):
super().__init__(message)
self.status_code = status_code
self.code = code
self.message = message
def map_command_error(stderr: str, exit_code: int) -> ApiError:
normalized = (stderr or "").lower()
if "already exists" in normalized:
return ApiError(409, "resource_conflict", stderr.strip())
if "does not exist" in normalized or "not found" in normalized:
return ApiError(404, "not_found", stderr.strip())
if "is currently used" in normalized or "cannot remove the primary group" in normalized:
return ApiError(422, "precondition_failed", stderr.strip())
if "permission denied" in normalized:
return ApiError(503, "system_permission_denied", stderr.strip())
if exit_code == 124:
return ApiError(503, "system_timeout", "System command timed out.")
return ApiError(500, "system_command_error", stderr.strip() or "Unknown command error.")

View File

@ -0,0 +1,66 @@
from typing import List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
USERNAME_PATTERN = r"^[a-z_][a-z0-9_-]{0,31}$"
GROUPNAME_PATTERN = r"^[a-z_][a-z0-9_-]{0,31}$"
class UserCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
username: str = Field(pattern=USERNAME_PATTERN)
password_hash: str = Field(min_length=10, max_length=512)
primary_group: Optional[str] = Field(default=None, pattern=GROUPNAME_PATTERN)
groups: List[str] = Field(default_factory=list)
@field_validator("groups")
@classmethod
def validate_groups(cls, value: List[str]) -> List[str]:
deduped = list(dict.fromkeys(value))
for group in deduped:
if not __import__("re").match(GROUPNAME_PATTERN, group):
raise ValueError(f"Invalid group name: {group}")
return deduped
class UserSummary(BaseModel):
username: str
uid: int
gid: int
home_dir: str
shell: str
class GroupCreateRequest(BaseModel):
groupname: str = Field(pattern=GROUPNAME_PATTERN)
class GroupSummary(BaseModel):
groupname: str
gid: int
members: List[str]
class UserGroupsUpdateRequest(BaseModel):
groups: List[str] = Field(min_length=1)
mode: Literal["append", "replace"] = "append"
@field_validator("groups")
@classmethod
def validate_groups(cls, value: List[str]) -> List[str]:
deduped = list(dict.fromkeys(value))
for group in deduped:
if not __import__("re").match(GROUPNAME_PATTERN, group):
raise ValueError(f"Invalid group name: {group}")
return deduped
class UserPasswordUpdateRequest(BaseModel):
password_hash: str = Field(min_length=10, max_length=512)
class ApiResponse(BaseModel):
status: str = "ok"
message: str

View File

@ -0,0 +1,21 @@
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.api.routes import router
from app.core.errors import ApiError
def create_app() -> FastAPI:
app = FastAPI(title="Ubuntu User Manage API", version="1.0.0")
app.include_router(router)
@app.exception_handler(ApiError)
async def api_error_handler(_: Request, exc: ApiError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"code": exc.code, "message": exc.message})
@app.exception_handler(RequestValidationError)
async def validation_error_handler(_: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(status_code=400, content={"code": "invalid_parameter", "message": str(exc.errors())})
return app

View File

@ -0,0 +1,33 @@
import app.container as container
from app.core.audit import AuditLogger
from app.core.config import Settings, validate_settings
from app.factory import create_app
from app.providers.cli_provider import CliSystemProvider, CommandExecutor
from app.services.user_group_service import UserGroupService
from app.state import AppState
settings = Settings()
validate_settings(settings)
executor = CommandExecutor(sudo_path=settings.sudo_path, timeout_seconds=settings.command_timeout_seconds)
provider = CliSystemProvider(executor=executor)
service = UserGroupService(
provider=provider,
home_base_dir=settings.home_base_dir,
link_home_dir=settings.link_home_dir or None,
default_shell=settings.default_shell,
hidden_users=settings.hidden_user_list,
hidden_groups=settings.hidden_group_list,
whitelist_users=settings.whitelist_user_list,
whitelist_groups=settings.whitelist_group_list,
locked_users=settings.locked_user_list,
user_uid_min=settings.user_uid_min,
user_uid_max=settings.user_uid_max,
group_gid_min=settings.group_gid_min,
group_gid_max=settings.group_gid_max,
)
audit = AuditLogger(log_path=settings.log_path)
container.app_state = AppState(settings=settings, service=service, audit=audit)
app_state = container.app_state
app = create_app()

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,54 @@
from abc import ABC, abstractmethod
from typing import List, Optional
from app.core.models import GroupSummary, UserSummary
class SystemProvider(ABC):
@abstractmethod
def create_user(self, username: str, password_hash: str, home_dir: Optional[str], linked_home_dir: Optional[str], shell: str, primary_group: Optional[str], groups: List[str]) -> None:
raise NotImplementedError
@abstractmethod
def delete_user(self, username: str) -> None:
raise NotImplementedError
@abstractmethod
def change_user_password(self, username: str, password_hash: str) -> None:
raise NotImplementedError
@abstractmethod
def list_users(self) -> List[UserSummary]:
raise NotImplementedError
@abstractmethod
def get_user(self, username: str) -> UserSummary:
raise NotImplementedError
@abstractmethod
def create_group(self, groupname: str) -> None:
raise NotImplementedError
@abstractmethod
def delete_group(self, groupname: str) -> None:
raise NotImplementedError
@abstractmethod
def list_groups(self) -> List[GroupSummary]:
raise NotImplementedError
@abstractmethod
def get_group(self, groupname: str) -> GroupSummary:
raise NotImplementedError
@abstractmethod
def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None:
raise NotImplementedError
@abstractmethod
def remove_user_groups(self, username: str, groups: List[str]) -> None:
raise NotImplementedError
@abstractmethod
def get_user_groups(self, username: str) -> List[str]:
raise NotImplementedError

View File

@ -0,0 +1,122 @@
from pathlib import Path
import subprocess
from typing import List, Optional
from app.core.errors import ApiError, map_command_error
from app.core.models import GroupSummary, UserSummary
from app.providers.base import SystemProvider
class CommandExecutor:
def __init__(self, sudo_path: str, timeout_seconds: int):
self.sudo_path = sudo_path
self.timeout_seconds = timeout_seconds
self.allowlist = {"useradd", "usermod", "userdel", "groupadd", "groupdel", "chpasswd", "id", "getent", "mkdir", "ln", "chown", "unlink"}
def run(self, args: List[str], use_sudo: bool = True) -> str:
if not args:
raise ApiError(500, "invalid_command", "Empty command.")
command = args[0]
if command not in self.allowlist:
raise ApiError(500, "forbidden_command", f"Command not allowlisted: {command}")
full = [command] + args[1:]
if use_sudo:
full = [self.sudo_path, "-n"] + full
try:
result = subprocess.run(full, capture_output=True, text=True, timeout=self.timeout_seconds, check=False)
except subprocess.TimeoutExpired as exception:
raise ApiError(503, "system_timeout", "System command timed out.") from exception
if result.returncode != 0:
raise map_command_error(result.stderr, result.returncode)
return result.stdout.strip()
class CliSystemProvider(SystemProvider):
def __init__(self, executor: CommandExecutor):
self.executor = executor
def create_user(self, username: str, password_hash: str, home_dir: Optional[str], linked_home_dir: Optional[str], shell: str, primary_group: Optional[str], groups: List[str]) -> None:
command = ["useradd", "-s", shell, "-p", password_hash]
if linked_home_dir:
command.append("-M")
else:
command.append("-m")
if home_dir:
command.extend(["-d", home_dir])
if primary_group:
command.extend(["-g", primary_group])
if groups:
command.extend(["-G", ",".join(groups)])
command.append(username)
self.executor.run(command)
if linked_home_dir and home_dir:
self.executor.run(["mkdir", "-p", linked_home_dir])
self.executor.run(["chown", "-R", username, linked_home_dir])
self.executor.run(["ln", "-s", linked_home_dir, home_dir])
def delete_user(self, username: str) -> None:
home_dir = self.get_user(username).home_dir
self.executor.run(["userdel", username])
if Path(home_dir).is_symlink():
self.executor.run(["unlink", home_dir])
def change_user_password(self, username: str, password_hash: str) -> None:
self.executor.run(["usermod", "-p", password_hash, username])
def list_users(self) -> List[UserSummary]:
output = self.executor.run(["getent", "passwd"], use_sudo=False)
users: List[UserSummary] = []
for line in output.splitlines():
parts = line.split(":")
if len(parts) < 7:
continue
username, _, uid, gid, _, home_dir, shell = parts[:7]
users.append(UserSummary(username=username, uid=int(uid), gid=int(gid), home_dir=home_dir, shell=shell))
return users
def get_user(self, username: str) -> UserSummary:
output = self.executor.run(["getent", "passwd", username], use_sudo=False)
if not output:
raise ApiError(404, "not_found", f"User not found: {username}")
parts = output.split(":")
return UserSummary(username=parts[0], uid=int(parts[2]), gid=int(parts[3]), home_dir=parts[5], shell=parts[6])
def create_group(self, groupname: str) -> None:
self.executor.run(["groupadd", groupname])
def delete_group(self, groupname: str) -> None:
self.executor.run(["groupdel", groupname])
def list_groups(self) -> List[GroupSummary]:
output = self.executor.run(["getent", "group"], use_sudo=False)
groups: List[GroupSummary] = []
for line in output.splitlines():
parts = line.split(":")
if len(parts) < 4:
continue
name, _, gid, members = parts[:4]
member_list = [member for member in members.split(",") if member]
groups.append(GroupSummary(groupname=name, gid=int(gid), members=member_list))
return groups
def get_group(self, groupname: str) -> GroupSummary:
output = self.executor.run(["getent", "group", groupname], use_sudo=False)
if not output:
raise ApiError(404, "not_found", f"Group not found: {groupname}")
parts = output.split(":")
members = [member for member in parts[3].split(",") if member]
return GroupSummary(groupname=parts[0], gid=int(parts[2]), members=members)
def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None:
mode_flag = "-G" if replace else "-aG"
self.executor.run(["usermod", mode_flag, ",".join(groups), username])
def remove_user_groups(self, username: str, groups: List[str]) -> None:
current = self.get_user_groups(username)
remaining = [group for group in current if group not in set(groups)]
self.executor.run(["usermod", "-G", ",".join(remaining), username])
def get_user_groups(self, username: str) -> List[str]:
output = self.executor.run(["id", "-nG", username], use_sudo=False)
return [group for group in output.split() if group]

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,174 @@
from pathlib import PurePosixPath
from typing import List, Optional
from app.core.errors import ApiError
from app.core.models import GroupSummary, UserCreateRequest, UserSummary
from app.providers.base import SystemProvider
class UserGroupService:
def __init__(
self,
provider: SystemProvider,
home_base_dir: str,
link_home_dir: Optional[str] = None,
default_shell: str = "/bin/bash",
hidden_users: Optional[List[str]] = None,
hidden_groups: Optional[List[str]] = None,
whitelist_users: Optional[List[str]] = None,
whitelist_groups: Optional[List[str]] = None,
locked_users: Optional[List[str]] = None,
user_uid_min: Optional[int] = None,
user_uid_max: Optional[int] = None,
group_gid_min: Optional[int] = None,
group_gid_max: Optional[int] = None,
):
self.provider = provider
self.home_base_dir = PurePosixPath(home_base_dir)
self.link_home_base_dir = PurePosixPath(link_home_dir) if link_home_dir else None
self.default_shell = default_shell
self.hidden_users = set(hidden_users or [])
self.hidden_groups = set(hidden_groups or [])
self.whitelist_users = set(whitelist_users or [])
self.whitelist_groups = set(whitelist_groups or [])
self.locked_users = set(locked_users or [])
self.user_uid_min = user_uid_min
self.user_uid_max = user_uid_max
self.group_gid_min = group_gid_min
self.group_gid_max = group_gid_max
def _ensure_user_visible(self, username: str) -> None:
user = self.provider.get_user(username)
if not self._is_user_visible(user):
raise ApiError(404, "not_found", "user not found")
def _ensure_groups_visible(self, groups: List[str]) -> None:
for groupname in groups:
self._ensure_group_visible(groupname)
def _ensure_group_visible(self, groupname: str) -> None:
group = self.provider.get_group(groupname)
if not self._is_group_visible(group):
raise ApiError(404, "not_found", "group not found")
def _ensure_user_name_allowed(self, username: str) -> None:
if username not in self.whitelist_users and username in self.hidden_users:
raise ApiError(404, "not_found", "user not found")
def _ensure_user_unlocked(self, username: str) -> None:
if username in self.locked_users:
raise ApiError(423, "user_locked", "user is locked and cannot be modified")
def _ensure_group_name_allowed(self, groupname: str) -> None:
if groupname not in self.whitelist_groups and groupname in self.hidden_groups:
raise ApiError(404, "not_found", "group not found")
def _is_uid_in_range(self, uid: int) -> bool:
if self.user_uid_min is not None and uid < self.user_uid_min:
return False
if self.user_uid_max is not None and uid > self.user_uid_max:
return False
return True
def _is_gid_in_range(self, gid: int) -> bool:
if self.group_gid_min is not None and gid < self.group_gid_min:
return False
if self.group_gid_max is not None and gid > self.group_gid_max:
return False
return True
def _is_user_visible(self, user: UserSummary) -> bool:
if user.username in self.whitelist_users:
return True
if user.username in self.hidden_users:
return False
return self._is_uid_in_range(user.uid)
def _is_group_visible(self, group: GroupSummary) -> bool:
if group.groupname in self.whitelist_groups:
return True
if group.groupname in self.hidden_groups:
return False
return self._is_gid_in_range(group.gid)
def _resolve_home_dir(self, username: str) -> str:
return str(self.home_base_dir / username)
def _resolve_linked_home_dir(self, username: str) -> Optional[str]:
if self.link_home_base_dir is None:
return None
return str(self.link_home_base_dir / username)
def create_user(self, payload: UserCreateRequest) -> None:
self._ensure_user_name_allowed(payload.username)
self._ensure_user_unlocked(payload.username)
if payload.primary_group is not None:
self._ensure_group_visible(payload.primary_group)
self._ensure_groups_visible(payload.groups)
home_dir = self._resolve_home_dir(payload.username)
linked_home_dir = self._resolve_linked_home_dir(payload.username)
self.provider.create_user(
username=payload.username,
password_hash=payload.password_hash,
home_dir=home_dir,
linked_home_dir=linked_home_dir,
shell=self.default_shell,
primary_group=payload.primary_group,
groups=payload.groups,
)
def delete_user(self, username: str) -> None:
self._ensure_user_visible(username)
self._ensure_user_unlocked(username)
self.provider.delete_user(username)
def change_user_password(self, username: str, password_hash: str) -> None:
self._ensure_user_visible(username)
self._ensure_user_unlocked(username)
self.provider.change_user_password(username, password_hash)
def list_users(self) -> List[UserSummary]:
return [user for user in self.provider.list_users() if self._is_user_visible(user)]
def get_user(self, username: str) -> UserSummary:
user = self.provider.get_user(username)
if not self._is_user_visible(user):
raise ApiError(404, "not_found", "user not found")
return user
def create_group(self, groupname: str) -> None:
self._ensure_group_name_allowed(groupname)
self.provider.create_group(groupname)
def delete_group(self, groupname: str) -> None:
self._ensure_group_visible(groupname)
group = self.provider.get_group(groupname)
if group.members:
raise ApiError(422, "precondition_failed", "Group has members and cannot be deleted.")
self.provider.delete_group(groupname)
def list_groups(self) -> List[GroupSummary]:
return [group for group in self.provider.list_groups() if self._is_group_visible(group)]
def get_group(self, groupname: str) -> GroupSummary:
group = self.provider.get_group(groupname)
if not self._is_group_visible(group):
raise ApiError(404, "not_found", "group not found")
return group
def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None:
self._ensure_user_visible(username)
self._ensure_user_unlocked(username)
self._ensure_groups_visible(groups)
self.provider.add_user_groups(username, groups, replace)
def remove_user_groups(self, username: str, groups: List[str]) -> None:
self._ensure_user_visible(username)
self._ensure_user_unlocked(username)
self._ensure_groups_visible(groups)
self.provider.remove_user_groups(username, groups)
def get_user_groups(self, username: str) -> List[str]:
self._ensure_user_visible(username)
return [group for group in self.provider.get_user_groups(username) if self._is_group_visible(self.provider.get_group(group))]

View File

@ -0,0 +1,12 @@
from dataclasses import dataclass
from app.core.audit import AuditLogger
from app.core.config import Settings
from app.services.user_group_service import UserGroupService
@dataclass
class AppState:
settings: Settings
service: UserGroupService
audit: AuditLogger

View File

@ -0,0 +1,30 @@
import requests
BASE_URL = "http://127.0.0.1:8000"
TOKEN = "replace-with-token"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
def main() -> None:
create_user = requests.post(
f"{BASE_URL}/users",
headers=HEADERS,
json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": ["dev"]},
timeout=10,
)
print("create_user:", create_user.status_code, create_user.text)
add_group = requests.post(
f"{BASE_URL}/users/alice/groups",
headers=HEADERS,
json={"groups": ["dev"], "mode": "append"},
timeout=10,
)
print("add_group:", add_group.status_code, add_group.text)
get_user = requests.get(f"{BASE_URL}/users/alice", headers=HEADERS, timeout=10)
print("get_user:", get_user.status_code, get_user.text)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,18 @@
{"operation": "create_user", "target": "admin", "result": "failed", "error_code": "system_command_error", "request_id": "", "source_ip": "127.0.0.1", "elapsed_ms": 13, "ts": 1779863595.5246663, "duration_ms": 0}
{"operation": "create_user", "target": "admin", "result": "failed", "error_code": "system_command_error", "request_id": "", "source_ip": "127.0.0.1", "elapsed_ms": 8, "ts": 1779863595.7691226, "duration_ms": 0}
{"operation": "create_user", "target": "testadmin", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779863611.8820772, "duration_ms": 0}
{"operation": "delete_user", "target": "testadmin", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864235.6534963, "duration_ms": 0}
{"operation": "create_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864389.9342248, "duration_ms": 0}
{"operation": "delete_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864398.5722318, "duration_ms": 0}
{"operation": "create_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864421.8503268, "duration_ms": 0}
{"operation": "delete_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864451.530417, "duration_ms": 0}
{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864549.259966, "duration_ms": 0}
{"operation": "change_user_password", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864630.5760016, "duration_ms": 0}
{"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864656.8571804, "duration_ms": 0}
{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779865162.184787, "duration_ms": 0}
{"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779865345.4267325, "duration_ms": 0}
{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943112.1322336, "duration_ms": 0}
{"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943132.5661097, "duration_ms": 0}
{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943176.5257142, "duration_ms": 0}
{"operation": "change_user_password", "target": "boenadmin", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943323.021569, "duration_ms": 0}
{"operation": "change_user_password", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779943479.098227, "duration_ms": 0}

View File

@ -0,0 +1,18 @@
2026-05-27 14:33:15,524 INFO operation=create_user target=admin result=failed code=system_command_error request_id=
2026-05-27 14:33:15,769 INFO operation=create_user target=admin result=failed code=system_command_error request_id=
2026-05-27 14:33:31,882 INFO operation=create_user target=testadmin result=success code=None request_id=
2026-05-27 14:43:55,653 INFO operation=delete_user target=testadmin result=success code=None request_id=
2026-05-27 14:46:29,934 INFO operation=create_user target=test result=success code=None request_id=
2026-05-27 14:46:38,572 INFO operation=delete_user target=test result=success code=None request_id=
2026-05-27 14:47:01,850 INFO operation=create_user target=test result=success code=None request_id=
2026-05-27 14:47:31,530 INFO operation=delete_user target=test result=success code=None request_id=
2026-05-27 14:49:09,260 INFO operation=create_user target=testtest result=success code=None request_id=
2026-05-27 14:50:30,576 INFO operation=change_user_password target=testtest result=success code=None request_id=
2026-05-27 14:50:56,857 INFO operation=delete_user target=testtest result=success code=None request_id=
2026-05-27 14:59:22,184 INFO operation=create_user target=testtest result=success code=None request_id=
2026-05-27 15:02:25,426 INFO operation=delete_user target=testtest result=success code=None request_id=
2026-05-28 12:38:32,132 INFO operation=create_user target=testtest result=success code=None request_id=
2026-05-28 12:38:52,566 INFO operation=delete_user target=testtest result=success code=None request_id=
2026-05-28 12:39:36,525 INFO operation=create_user target=testtest result=success code=None request_id=
2026-05-28 12:42:03,021 INFO operation=change_user_password target=boenadmin result=success code=None request_id=
2026-05-28 12:44:39,098 INFO operation=change_user_password target=testtest result=success code=None request_id=

View File

@ -0,0 +1,6 @@
fastapi==0.115.0
uvicorn==0.30.6
pydantic==2.9.2
pydantic-settings==2.5.2
httpx==0.27.2
pytest==8.3.3

View File

@ -0,0 +1,60 @@
from pathlib import Path
import json
from typing import List, Optional
from fastapi.openapi.utils import get_openapi
import app.container as container
from app.core.audit import AuditLogger
from app.core.config import Settings
from app.factory import create_app
from app.providers.base import SystemProvider
from app.services.user_group_service import UserGroupService
from app.state import AppState
class DummyProvider(SystemProvider):
def create_user(self, username: str, password_hash: str, home_dir: Optional[str], linked_home_dir: Optional[str], shell: str, primary_group: Optional[str], groups: List[str]) -> None:
return None
def delete_user(self, username: str) -> None:
return None
def change_user_password(self, username: str, password_hash: str) -> None:
return None
def list_users(self):
return []
def get_user(self, username: str):
raise NotImplementedError
def create_group(self, groupname: str) -> None:
return None
def delete_group(self, groupname: str) -> None:
return None
def list_groups(self):
return []
def get_group(self, groupname: str):
raise NotImplementedError
def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None:
return None
def remove_user_groups(self, username: str, groups: List[str]) -> None:
return None
def get_user_groups(self, username: str):
return []
def main() -> None:
settings = Settings(TOKEN="sdk-token")
container.app_state = AppState(
settings=settings,
service=UserGroupService(provider=DummyProvider(), home_base_dir="/home"),
audit=AuditLogger("./logs/sdk_audit.log"),
)
app = create_app()
schema = get_openapi(title=app.title, version=app.version, routes=app.routes)
output = Path("sdk/openapi.json")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8")
print("OpenAPI exported to sdk/openapi.json")
print("Run: openapi-python-client generate --path sdk/openapi.json --output-path sdk/python_client")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,32 @@
from passlib.hash import sha512_crypt
import random
import string
import time
username = "test"
password = "test"
# 生成16位随机salt
salt = ''.join(
random.choices(
string.ascii_letters + string.digits,
k=16
)
)
# salt = 'uu2JGUu15xlX4eWn'
# Ubuntu SHA512 shadow格式
hashed = sha512_crypt.hash(
password,
salt=salt,
rounds=5000
)
# Linux shadow日期
days = int(time.time() / 86400)
# 拼接shadow内容
shadow = f"{username}:{hashed}:{days}:0:99999:7:::"
print(shadow)

View File

@ -0,0 +1,267 @@
from fastapi.testclient import TestClient
from typing import Dict, List, Optional
import app.container as container
from app.core.audit import AuditLogger
from app.core.config import Settings
from app.core.errors import ApiError
from app.factory import create_app
from app.core.models import GroupSummary, UserSummary
from app.providers.base import SystemProvider
from app.services.user_group_service import UserGroupService
from app.state import AppState
class MockProvider(SystemProvider):
def __init__(self):
self.users: Dict[str, UserSummary] = {}
self.groups: Dict[str, GroupSummary] = {}
self.user_group_map: Dict[str, List[str]] = {}
def create_user(self, username: str, password_hash: str, home_dir: Optional[str], linked_home_dir: Optional[str], shell: str, primary_group: Optional[str], groups: List[str]) -> None:
if username in self.users:
raise ApiError(409, "resource_conflict", "user exists")
self.users[username] = UserSummary(username=username, uid=1000, gid=1000, home_dir=home_dir or f"/home/{username}", shell=shell)
self.user_group_map[username] = groups
def delete_user(self, username: str) -> None:
if username not in self.users:
raise ApiError(404, "not_found", "user not found")
del self.users[username]
self.user_group_map.pop(username, None)
def change_user_password(self, username: str, password_hash: str) -> None:
if username not in self.users:
raise ApiError(404, "not_found", "user not found")
def list_users(self) -> List[UserSummary]:
return list(self.users.values())
def get_user(self, username: str) -> UserSummary:
if username not in self.users:
raise ApiError(404, "not_found", "user not found")
return self.users[username]
def create_group(self, groupname: str) -> None:
if groupname in self.groups:
raise ApiError(409, "resource_conflict", "group exists")
self.groups[groupname] = GroupSummary(groupname=groupname, gid=1000, members=[])
def delete_group(self, groupname: str) -> None:
if groupname not in self.groups:
raise ApiError(404, "not_found", "group not found")
del self.groups[groupname]
def list_groups(self) -> List[GroupSummary]:
return list(self.groups.values())
def get_group(self, groupname: str) -> GroupSummary:
if groupname not in self.groups:
raise ApiError(404, "not_found", "group not found")
return self.groups[groupname]
def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None:
if username not in self.users:
raise ApiError(404, "not_found", "user not found")
if replace:
self.user_group_map[username] = groups
else:
self.user_group_map[username] = list(dict.fromkeys(self.user_group_map.get(username, []) + groups))
def remove_user_groups(self, username: str, groups: List[str]) -> None:
if username not in self.users:
raise ApiError(404, "not_found", "user not found")
self.user_group_map[username] = [item for item in self.user_group_map.get(username, []) if item not in set(groups)]
def get_user_groups(self, username: str) -> List[str]:
if username not in self.users:
raise ApiError(404, "not_found", "user not found")
return self.user_group_map.get(username, [])
def build_client(
link_home_dir: Optional[str] = None,
hidden_users: Optional[List[str]] = None,
hidden_groups: Optional[List[str]] = None,
whitelist_users: Optional[List[str]] = None,
whitelist_groups: Optional[List[str]] = None,
locked_users: Optional[List[str]] = None,
user_uid_min: Optional[int] = None,
user_uid_max: Optional[int] = None,
group_gid_min: Optional[int] = None,
group_gid_max: Optional[int] = None,
) -> TestClient:
settings = Settings(TOKEN="test-token")
service = UserGroupService(
provider=MockProvider(),
home_base_dir="/home",
link_home_dir=link_home_dir,
hidden_users=hidden_users,
hidden_groups=hidden_groups,
whitelist_users=whitelist_users,
whitelist_groups=whitelist_groups,
locked_users=locked_users,
user_uid_min=user_uid_min,
user_uid_max=user_uid_max,
group_gid_min=group_gid_min,
group_gid_max=group_gid_max,
)
audit = AuditLogger("./logs/test_audit.log")
container.app_state = AppState(settings=settings, service=service, audit=audit)
return TestClient(create_app())
def test_user_group_happy_path() -> None:
client = build_client()
headers = {"Authorization": "Bearer test-token"}
response = client.post("/groups", json={"groupname": "dev"}, headers=headers)
assert response.status_code == 200
response = client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": ["dev"]}, headers=headers)
assert response.status_code == 200
response = client.post("/users/alice/groups", json={"groups": ["dev"], "mode": "append"}, headers=headers)
assert response.status_code == 200
response = client.get("/users/alice/groups", headers=headers)
assert response.status_code == 200
assert "dev" in response.json()["groups"]
def test_health_returns_server_name_and_online_status() -> None:
client = build_client()
headers = {"Authorization": "Bearer test-token"}
response = client.get("/health", headers=headers)
assert response.status_code == 200
assert response.json() == {"server_name": "user-manage-api", "status": "online"}
def test_change_user_password() -> None:
client = build_client()
headers = {"Authorization": "Bearer test-token"}
client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers)
response = client.patch("/users/alice/password", json={"password_hash": "$6$rounds=5000$newhashvalue"}, headers=headers)
assert response.status_code == 200
assert response.json()["message"] == "User password updated."
def test_create_user_rejects_client_shell_and_home_dir() -> None:
client = build_client()
headers = {"Authorization": "Bearer test-token"}
payload = {
"username": "alice",
"password_hash": "$6$rounds=5000$abcdefghij",
"shell": "/bin/sh",
"home_dir": "/tmp/alice",
}
response = client.post("/users", json=payload, headers=headers)
assert response.status_code == 400
assert response.json()["code"] == "invalid_parameter"
def test_locked_user_can_be_read_but_not_modified() -> None:
client = build_client(locked_users=["alice"])
headers = {"Authorization": "Bearer test-token"}
provider = container.app_state.service.provider
provider.users["alice"] = UserSummary(username="alice", uid=1000, gid=1000, home_dir="/home/alice", shell="/bin/bash")
response = client.get("/users/alice", headers=headers)
assert response.status_code == 200
response = client.patch("/users/alice/password", json={"password_hash": "$6$rounds=5000$newhashvalue"}, headers=headers)
assert response.status_code == 423
response = client.delete("/users/alice", headers=headers)
assert response.status_code == 423
response = client.post("/users/alice/groups", json={"groups": ["dev"], "mode": "append"}, headers=headers)
assert response.status_code == 423
def test_conflict_and_not_found_codes() -> None:
client = build_client()
headers = {"Authorization": "Bearer test-token"}
payload = {"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}
response = client.post("/users", json=payload, headers=headers)
assert response.status_code == 200
response = client.post("/users", json=payload, headers=headers)
assert response.status_code == 409
response = client.delete("/users/bob", headers=headers)
assert response.status_code == 404
def test_hidden_users_and_groups_are_filtered_and_blocked() -> None:
client = build_client(hidden_users=["root"], hidden_groups=["sudo"])
headers = {"Authorization": "Bearer test-token"}
client.post("/users", json={"username": "alice", "password_hash": "$6$rounds=5000$abcdefghij", "groups": []}, headers=headers)
client.post("/groups", json={"groupname": "dev"}, headers=headers)
provider = container.app_state.service.provider
provider.users["root"] = UserSummary(username="root", uid=0, gid=0, home_dir="/root", shell="/bin/bash")
provider.groups["sudo"] = GroupSummary(groupname="sudo", gid=27, members=["root"])
provider.user_group_map["alice"] = ["dev", "sudo"]
response = client.get("/users", headers=headers)
assert response.status_code == 200
assert [user["username"] for user in response.json()] == ["alice"]
response = client.get("/groups", headers=headers)
assert response.status_code == 200
assert [group["groupname"] for group in response.json()] == ["dev"]
response = client.get("/users/root", headers=headers)
assert response.status_code == 404
response = client.delete("/groups/sudo", headers=headers)
assert response.status_code == 404
response = client.get("/users/alice/groups", headers=headers)
assert response.status_code == 200
assert response.json()["groups"] == ["dev"]
def test_whitelist_overrides_hidden_and_id_ranges() -> None:
client = build_client(
hidden_users=["root"],
hidden_groups=["sudo"],
whitelist_users=["root"],
whitelist_groups=["sudo"],
user_uid_min=1000,
user_uid_max=2000,
group_gid_min=1000,
group_gid_max=2000,
)
headers = {"Authorization": "Bearer test-token"}
provider = container.app_state.service.provider
provider.users["root"] = UserSummary(username="root", uid=0, gid=0, home_dir="/root", shell="/bin/bash")
provider.users["alice"] = UserSummary(username="alice", uid=1000, gid=1000, home_dir="/home/alice", shell="/bin/bash")
provider.users["system"] = UserSummary(username="system", uid=500, gid=500, home_dir="/home/system", shell="/bin/bash")
provider.groups["sudo"] = GroupSummary(groupname="sudo", gid=27, members=["root"])
provider.groups["dev"] = GroupSummary(groupname="dev", gid=1000, members=[])
provider.groups["system"] = GroupSummary(groupname="system", gid=500, members=[])
response = client.get("/users", headers=headers)
assert response.status_code == 200
assert [user["username"] for user in response.json()] == ["root", "alice"]
response = client.get("/groups", headers=headers)
assert response.status_code == 200
assert [group["groupname"] for group in response.json()] == ["sudo", "dev"]
response = client.get("/users/root", headers=headers)
assert response.status_code == 200
response = client.get("/users/system", headers=headers)
assert response.status_code == 404

View File

@ -0,0 +1,113 @@
from typing import List, Optional
from app.core.errors import ApiError, map_command_error
from app.core.models import UserCreateRequest
import app.providers.cli_provider as cli_provider
from app.providers.cli_provider import CliSystemProvider
from app.providers.base import SystemProvider
from app.services.user_group_service import UserGroupService
class NoopProvider(SystemProvider):
def __init__(self) -> None:
self.created_home_dir: Optional[str] = None
self.created_linked_home_dir: Optional[str] = None
self.created_shell: Optional[str] = None
def create_user(self, username: str, password_hash: str, home_dir: Optional[str], linked_home_dir: Optional[str], shell: str, primary_group: Optional[str], groups: List[str]) -> None:
self.created_home_dir = home_dir
self.created_linked_home_dir = linked_home_dir
self.created_shell = shell
return None
def delete_user(self, username: str) -> None:
return None
def change_user_password(self, username: str, password_hash: str) -> None:
return None
def list_users(self):
return []
def get_user(self, username: str):
raise ApiError(404, "not_found", "not found")
def create_group(self, groupname: str) -> None:
return None
def delete_group(self, groupname: str) -> None:
return None
def list_groups(self):
return []
def get_group(self, groupname: str):
raise ApiError(404, "not_found", "not found")
def add_user_groups(self, username: str, groups: List[str], replace: bool) -> None:
return None
def remove_user_groups(self, username: str, groups: List[str]) -> None:
return None
def get_user_groups(self, username: str):
return []
def test_create_user_uses_configured_shell_and_home_dir() -> None:
provider = NoopProvider()
service = UserGroupService(provider=provider, home_base_dir="/srv/home", default_shell="/usr/sbin/nologin")
payload = UserCreateRequest(username="alice", password_hash="$6$rounds=5000$abcdefghij", groups=[])
service.create_user(payload)
assert provider.created_home_dir == "/srv/home/alice"
assert provider.created_shell == "/usr/sbin/nologin"
def test_link_home_dir_uses_home_base_symlink_path_and_external_storage() -> None:
provider = NoopProvider()
service = UserGroupService(provider=provider, home_base_dir="/home", link_home_dir="/data/home")
payload = UserCreateRequest(username="alice", password_hash="$6$rounds=5000$abcdefghij", groups=[])
service.create_user(payload)
assert provider.created_home_dir == "/home/alice"
assert provider.created_linked_home_dir == "/data/home/alice"
def test_command_error_mapping_conflict() -> None:
error = map_command_error("user already exists", 9)
assert error.status_code == 409
class RecordingExecutor:
def __init__(self, home_dir: str) -> None:
self.home_dir = home_dir
self.commands: List[List[str]] = []
def run(self, args: List[str], use_sudo: bool = True) -> str:
self.commands.append(args)
if args == ["getent", "passwd", "alice"]:
return f"alice:x:1000:1000::%s:/bin/bash" % self.home_dir
return ""
class FakePath:
def __init__(self, path: str) -> None:
self.path = path
def is_symlink(self) -> bool:
return self.path == "/home/alice"
def test_delete_user_unlinks_home_when_it_is_symlink(monkeypatch) -> None:
monkeypatch.setattr(cli_provider, "Path", FakePath)
executor = RecordingExecutor("/home/alice")
provider = CliSystemProvider(executor=executor)
provider.delete_user("alice")
assert ["userdel", "alice"] in executor.commands
assert ["unlink", "/home/alice"] in executor.commands
def test_delete_user_does_not_unlink_home_when_it_is_directory(monkeypatch) -> None:
monkeypatch.setattr(cli_provider, "Path", FakePath)
executor = RecordingExecutor("/home/old-alice")
provider = CliSystemProvider(executor=executor)
provider.delete_user("alice")
assert ["userdel", "alice"] in executor.commands
assert ["unlink", "/home/old-alice"] not in executor.commands