Compare commits
3 Commits
309d634851
...
884e0235b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 884e0235b0 | |||
| ca023c23f8 | |||
| 92cb45e72d |
1
.gitignore
vendored
1
.gitignore
vendored
@ -31,3 +31,4 @@ CLAUDE.md
|
|||||||
.mcp.json
|
.mcp.json
|
||||||
boost.json
|
boost.json
|
||||||
LOG.md
|
LOG.md
|
||||||
|
*/.venv*
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/Exceptions/Console/Commands/CheckBastionLoginCommand.php
Normal file
177
app/Exceptions/Console/Commands/CheckBastionLoginCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
376
app/Exceptions/Console/Commands/UserManageCommand.php
Normal file
376
app/Exceptions/Console/Commands/UserManageCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -70,10 +70,7 @@ class OpsClientController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name'],
|
'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name'],
|
||||||
'bastion_protocol_id' => ['required', 'integer', 'min:1'],
|
|
||||||
'description' => ['nullable', 'string', 'max:255'],
|
'description' => ['nullable', 'string', 'max:255'],
|
||||||
'sort' => ['sometimes', 'integer', 'min:0'],
|
|
||||||
'is_active' => ['sometimes', 'boolean'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$protocol = OpsProtocol::query()->create($validated);
|
$protocol = OpsProtocol::query()->create($validated);
|
||||||
@ -88,10 +85,7 @@ class OpsClientController extends Controller
|
|||||||
$protocol = OpsProtocol::query()->findOrFail($id);
|
$protocol = OpsProtocol::query()->findOrFail($id);
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name,'.$protocol->id],
|
'name' => ['required', 'string', 'max:64', 'unique:ops_protocols,name,'.$protocol->id],
|
||||||
'bastion_protocol_id' => ['required', 'integer', 'min:1'],
|
|
||||||
'description' => ['nullable', 'string', 'max:255'],
|
'description' => ['nullable', 'string', 'max:255'],
|
||||||
'sort' => ['sometimes', 'integer', 'min:0'],
|
|
||||||
'is_active' => ['sometimes', 'boolean'],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$protocol->update($validated);
|
$protocol->update($validated);
|
||||||
@ -116,8 +110,6 @@ class OpsClientController extends Controller
|
|||||||
OpsProtocol::query()->findOrFail($id);
|
OpsProtocol::query()->findOrFail($id);
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:100'],
|
'name' => ['required', 'string', 'max:100'],
|
||||||
'client_path' => ['nullable', 'string', 'max:255'],
|
|
||||||
'sort' => ['sometimes', 'integer', 'min:0'],
|
|
||||||
'is_active' => ['sometimes', 'boolean'],
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -146,8 +138,6 @@ class OpsClientController extends Controller
|
|||||||
$software = OpsSoftware::query()->findOrFail($id);
|
$software = OpsSoftware::query()->findOrFail($id);
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:100'],
|
'name' => ['required', 'string', 'max:100'],
|
||||||
'client_path' => ['nullable', 'string', 'max:255'],
|
|
||||||
'sort' => ['sometimes', 'integer', 'min:0'],
|
|
||||||
'is_active' => ['sometimes', 'boolean'],
|
'is_active' => ['sometimes', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@ use App\Models\AccessLog;
|
|||||||
use App\Models\BastionAccount;
|
use App\Models\BastionAccount;
|
||||||
use App\Models\OpsProtocol;
|
use App\Models\OpsProtocol;
|
||||||
use App\Models\ServerResource;
|
use App\Models\ServerResource;
|
||||||
|
use App\Models\ServerUserBinding;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserServerPermission;
|
use App\Models\UserServerPermission;
|
||||||
use hg\apidoc\annotation as Apidoc;
|
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
|
private function resolveResourceIdsFromPermissions(User $user): Collection
|
||||||
@ -125,6 +129,8 @@ class ServerResourceController extends Controller
|
|||||||
if ($isResource) {
|
if ($isResource) {
|
||||||
$parent = ServerResource::query()->findOrFail($targetParentId);
|
$parent = ServerResource::query()->findOrFail($targetParentId);
|
||||||
$data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip;
|
$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['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), null)];
|
||||||
$data['display_name'] = $data['display_name'] ?? $data['name'];
|
$data['display_name'] = $data['display_name'] ?? $data['name'];
|
||||||
$data['asset_id'] = $parent->asset_id;
|
$data['asset_id'] = $parent->asset_id;
|
||||||
@ -144,6 +150,9 @@ class ServerResourceController extends Controller
|
|||||||
$data['protocols'] = [];
|
$data['protocols'] = [];
|
||||||
$data['account_id'] = null;
|
$data['account_id'] = null;
|
||||||
$data['display_name'] = $data['display_name'] ?? $data['name'];
|
$data['display_name'] = $data['display_name'] ?? $data['name'];
|
||||||
|
if (trim((string) ($data['user_api_token'] ?? '')) === '') {
|
||||||
|
$data['user_api_token'] = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unset($data['protocol']);
|
unset($data['protocol']);
|
||||||
@ -158,7 +167,10 @@ class ServerResourceController extends Controller
|
|||||||
#[Apidoc\Title('资源详情'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}')]
|
#[Apidoc\Title('资源详情'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}')]
|
||||||
public function show(int $id): JsonResponse
|
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}')]
|
#[Apidoc\Title('更新资源'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}')]
|
||||||
@ -175,6 +187,8 @@ class ServerResourceController extends Controller
|
|||||||
if ($isResource) {
|
if ($isResource) {
|
||||||
$parent = ServerResource::query()->findOrFail($targetParentId);
|
$parent = ServerResource::query()->findOrFail($targetParentId);
|
||||||
$data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip;
|
$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['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), (string) ($server->protocols[0] ?? ''))];
|
||||||
$data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']);
|
$data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']);
|
||||||
$data['asset_id'] = $parent->asset_id;
|
$data['asset_id'] = $parent->asset_id;
|
||||||
@ -194,6 +208,9 @@ class ServerResourceController extends Controller
|
|||||||
$data['protocols'] = [];
|
$data['protocols'] = [];
|
||||||
$data['account_id'] = null;
|
$data['account_id'] = null;
|
||||||
$data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']);
|
$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']);
|
unset($data['protocol']);
|
||||||
@ -493,6 +510,7 @@ class ServerResourceController extends Controller
|
|||||||
'protocol' => $protocol,
|
'protocol' => $protocol,
|
||||||
'resource_id' => $resource->id,
|
'resource_id' => $resource->id,
|
||||||
'resource_name' => $resource->display_name ?: $resource->name,
|
'resource_name' => $resource->display_name ?: $resource->name,
|
||||||
|
'server_username' => $this->boundServerUsername($user, $resource),
|
||||||
'bastion_account_id' => $bastionAccount->id,
|
'bastion_account_id' => $bastionAccount->id,
|
||||||
'client_type' => (string) data_get($result, 'data.client_type', ''),
|
'client_type' => (string) data_get($result, 'data.client_type', ''),
|
||||||
'response' => $result,
|
'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
|
private function extractSsoTokenFromUrl(string $ssoUrl): ?string
|
||||||
{
|
{
|
||||||
if (! str_starts_with($ssoUrl, 'sso://')) {
|
if (! str_starts_with($ssoUrl, 'sso://')) {
|
||||||
|
|||||||
307
app/Http/Controllers/Api/ServerSystemUserController.php
Normal file
307
app/Http/Controllers/Api/ServerSystemUserController.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,8 +6,10 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Http\Requests\StoreUserRequest;
|
use App\Http\Requests\StoreUserRequest;
|
||||||
use App\Http\Requests\UpdateUserRequest;
|
use App\Http\Requests\UpdateUserRequest;
|
||||||
use App\Models\ServerResource;
|
use App\Models\ServerResource;
|
||||||
|
use App\Models\ServerUserBinding;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserServerPermission;
|
use App\Models\UserServerPermission;
|
||||||
|
use App\Services\ServerUserManagementClient;
|
||||||
use hg\apidoc\annotation as Apidoc;
|
use hg\apidoc\annotation as Apidoc;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@ -20,11 +22,11 @@ use Spatie\Permission\Models\Role;
|
|||||||
#[Apidoc\Title('用户管理')]
|
#[Apidoc\Title('用户管理')]
|
||||||
class UserController extends Controller
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct(private ServerUserManagementClient $serverUserClient)
|
||||||
{
|
{
|
||||||
$this->middleware('auth:api');
|
$this->middleware('auth:api');
|
||||||
$this->middleware('permission:platform.users.view,api')->only(['index', 'show']);
|
$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')]
|
#[Apidoc\Title('用户列表'), Apidoc\Method('GET'), Apidoc\Url('/users')]
|
||||||
@ -41,7 +43,7 @@ class UserController extends Controller
|
|||||||
$perPage = (int) ($validated['per_page'] ?? 20);
|
$perPage = (int) ($validated['per_page'] ?? 20);
|
||||||
|
|
||||||
$users = User::query()
|
$users = User::query()
|
||||||
->with('roles')
|
->with(['roles', 'serverUserBindings.server'])
|
||||||
->orderBy($sortBy, $sortOrder)
|
->orderBy($sortBy, $sortOrder)
|
||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
|
|
||||||
@ -51,21 +53,26 @@ class UserController extends Controller
|
|||||||
#[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')]
|
#[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')]
|
||||||
public function store(StoreUserRequest $request): JsonResponse
|
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')) {
|
if ($request->filled('role_ids')) {
|
||||||
$user->syncRoles($request->validated('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]]);
|
$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}')]
|
#[Apidoc\Title('用户详情'), Apidoc\Method('GET'), Apidoc\Url('/users/{id}')]
|
||||||
public function show(int $id): JsonResponse
|
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]);
|
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user]);
|
||||||
}
|
}
|
||||||
@ -78,7 +85,7 @@ class UserController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()->findOrFail($id);
|
$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')) {
|
if ($request->filled('password')) {
|
||||||
$user->password = $request->validated('password');
|
$user->password = $request->validated('password');
|
||||||
@ -90,9 +97,33 @@ class UserController extends Controller
|
|||||||
$user->syncRoles($request->validated('role_ids'));
|
$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]]);
|
$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')]
|
#[Apidoc\Title('同步用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/permissions')]
|
||||||
@ -157,6 +188,7 @@ class UserController extends Controller
|
|||||||
{
|
{
|
||||||
$user = User::query()->findOrFail($id);
|
$user = User::query()->findOrFail($id);
|
||||||
$this->auditLog($request, 'user_delete', ['metadata' => ['target_user_id' => $user->id]]);
|
$this->auditLog($request, 'user_delete', ['metadata' => ['target_user_id' => $user->id]]);
|
||||||
|
ServerUserBinding::query()->where('user_id', $user->id)->delete();
|
||||||
$user->delete();
|
$user->delete();
|
||||||
|
|
||||||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
|
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
|
||||||
@ -502,6 +534,75 @@ class UserController extends Controller
|
|||||||
$zip->close();
|
$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
|
private function syncServerResourcePermissionsByDirectPermissions(User $user, array $permissionIds): void
|
||||||
{
|
{
|
||||||
$resourcePermissions = Permission::query()
|
$resourcePermissions = Permission::query()
|
||||||
|
|||||||
@ -18,6 +18,8 @@ class StoreServerResourceRequest extends FormRequest
|
|||||||
'display_name' => ['nullable', 'string', 'max:255'],
|
'display_name' => ['nullable', 'string', 'max:255'],
|
||||||
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
|
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
|
||||||
'internal_ip' => ['nullable', 'ip'],
|
'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'],
|
'asset_id' => ['nullable', 'integer', 'min:1'],
|
||||||
'account_id' => ['nullable', 'integer', 'min:1'],
|
'account_id' => ['nullable', 'integer', 'min:1'],
|
||||||
'protocol' => ['nullable', 'string', 'max:64'],
|
'protocol' => ['nullable', 'string', 'max:64'],
|
||||||
|
|||||||
@ -21,6 +21,13 @@ class StoreUserRequest extends FormRequest
|
|||||||
'force_password_change' => ['sometimes', 'boolean'],
|
'force_password_change' => ['sometimes', 'boolean'],
|
||||||
'role_ids' => ['sometimes', 'array'],
|
'role_ids' => ['sometimes', 'array'],
|
||||||
'role_ids.*' => ['integer', 'exists:roles,id'],
|
'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}$/'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,8 @@ class UpdateServerResourceRequest extends FormRequest
|
|||||||
'display_name' => ['nullable', 'string', 'max:255'],
|
'display_name' => ['nullable', 'string', 'max:255'],
|
||||||
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
|
'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'],
|
||||||
'internal_ip' => ['nullable', 'ip'],
|
'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'],
|
'asset_id' => ['nullable', 'integer', 'min:1'],
|
||||||
'account_id' => ['nullable', 'integer', 'min:1'],
|
'account_id' => ['nullable', 'integer', 'min:1'],
|
||||||
'protocol' => ['nullable', 'string', 'max:64'],
|
'protocol' => ['nullable', 'string', 'max:64'],
|
||||||
|
|||||||
@ -24,6 +24,13 @@ class UpdateUserRequest extends FormRequest
|
|||||||
'force_password_change' => ['sometimes', 'boolean'],
|
'force_password_change' => ['sometimes', 'boolean'],
|
||||||
'role_ids' => ['sometimes', 'array'],
|
'role_ids' => ['sometimes', 'array'],
|
||||||
'role_ids.*' => ['integer', 'exists:roles,id'],
|
'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}$/'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,8 @@ class ServerResource extends Model
|
|||||||
'display_name',
|
'display_name',
|
||||||
'parent_id',
|
'parent_id',
|
||||||
'internal_ip',
|
'internal_ip',
|
||||||
|
'user_api_base_url',
|
||||||
|
'user_api_token',
|
||||||
'asset_id',
|
'asset_id',
|
||||||
'account_id',
|
'account_id',
|
||||||
'protocols',
|
'protocols',
|
||||||
@ -25,6 +27,10 @@ class ServerResource extends Model
|
|||||||
'is_active',
|
'is_active',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'user_api_token',
|
||||||
|
];
|
||||||
|
|
||||||
public function parent(): BelongsTo
|
public function parent(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(self::class, 'parent_id');
|
return $this->belongsTo(self::class, 'parent_id');
|
||||||
@ -42,6 +48,11 @@ class ServerResource extends Model
|
|||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function serverUserBindings(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ServerUserBinding::class, 'server_resource_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function accessLogs(): HasMany
|
public function accessLogs(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(AccessLog::class);
|
return $this->hasMany(AccessLog::class);
|
||||||
@ -51,6 +62,7 @@ class ServerResource extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'protocols' => 'array',
|
'protocols' => 'array',
|
||||||
|
'user_api_token' => 'encrypted',
|
||||||
'allow_copy_temp_password' => 'boolean',
|
'allow_copy_temp_password' => 'boolean',
|
||||||
'is_active' => 'boolean',
|
'is_active' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|||||||
40
app/Models/ServerUserBinding.php
Normal file
40
app/Models/ServerUserBinding.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,6 +48,11 @@ class User extends Authenticatable implements JWTSubject
|
|||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function serverUserBindings(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ServerUserBinding::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function opsSoftwarePreferences(): HasMany
|
public function opsSoftwarePreferences(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(UserOpsSoftwarePreference::class);
|
return $this->hasMany(UserOpsSoftwarePreference::class);
|
||||||
|
|||||||
139
app/Services/ServerUserManagementClient.php
Normal file
139
app/Services/ServerUserManagementClient.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -64,6 +64,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'guard_name' => '守卫',
|
'guard_name' => '守卫',
|
||||||
'parent_id' => '所属服务器',
|
'parent_id' => '所属服务器',
|
||||||
'internal_ip' => '内网IP',
|
'internal_ip' => '内网IP',
|
||||||
|
'user_api_base_url' => '用户管理API地址',
|
||||||
|
'user_api_token' => '用户管理API密钥',
|
||||||
'asset_id' => '资产ID',
|
'asset_id' => '资产ID',
|
||||||
'account_id' => '账号ID',
|
'account_id' => '账号ID',
|
||||||
'protocol' => '协议',
|
'protocol' => '协议',
|
||||||
@ -87,6 +89,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'per_page' => '每页数量',
|
'per_page' => '每页数量',
|
||||||
'username' => '用户名',
|
'username' => '用户名',
|
||||||
'token' => '令牌',
|
'token' => '令牌',
|
||||||
|
'server_bindings' => '服务器账号绑定',
|
||||||
|
'server_resource_id' => '服务器',
|
||||||
|
'password_hash' => '服务器账号密码',
|
||||||
|
'groups' => '用户组',
|
||||||
|
'groupname' => '用户组',
|
||||||
];
|
];
|
||||||
|
|
||||||
$resolveAttribute = function (string $field) use ($attributeLabels): string {
|
$resolveAttribute = function (string $field) use ($attributeLabels): string {
|
||||||
|
|||||||
@ -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' => [
|
'ops_client' => [
|
||||||
'ipv4' => env('OPS_CLIENT_IPV4', '172.16.1.2'),
|
'ipv4' => env('OPS_CLIENT_IPV4', '172.16.1.2'),
|
||||||
'asset_ipv4' => env('OPS_CLIENT_ASSET_IPV4', '0.0.0.0'),
|
'asset_ipv4' => env('OPS_CLIENT_ASSET_IPV4', '0.0.0.0'),
|
||||||
|
|||||||
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
167
tests/Feature/ServerSystemUserManagementTest.php
Normal file
167
tests/Feature/ServerSystemUserManagementTest.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
user_manage_api/.env.example
Normal file
18
user_manage_api/.env.example
Normal 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
36
user_manage_api/README.md
Normal 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
|
||||||
|
```
|
||||||
588
user_manage_api/api_document.md
Normal file
588
user_manage_api/api_document.md
Normal 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
|
||||||
|
```
|
||||||
|
|
||||||
|
描述:
|
||||||
|
|
||||||
|
创建系统用户。密码必须由调用方提前生成 hash,API 不处理明文密码。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```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"
|
||||||
|
}'
|
||||||
|
```
|
||||||
1
user_manage_api/app/__init__.py
Normal file
1
user_manage_api/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
user_manage_api/app/api/__init__.py
Normal file
1
user_manage_api/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
23
user_manage_api/app/api/deps.py
Normal file
23
user_manage_api/app/api/deps.py
Normal 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", ""),
|
||||||
|
}
|
||||||
98
user_manage_api/app/api/routes.py
Normal file
98
user_manage_api/app/api/routes.py
Normal 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)}
|
||||||
3
user_manage_api/app/container.py
Normal file
3
user_manage_api/app/container.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from app.state import AppState
|
||||||
|
|
||||||
|
app_state: AppState
|
||||||
1
user_manage_api/app/core/__init__.py
Normal file
1
user_manage_api/app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
49
user_manage_api/app/core/audit.py
Normal file
49
user_manage_api/app/core/audit.py
Normal 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")
|
||||||
91
user_manage_api/app/core/config.py
Normal file
91
user_manage_api/app/core/config.py
Normal 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}")
|
||||||
22
user_manage_api/app/core/errors.py
Normal file
22
user_manage_api/app/core/errors.py
Normal 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.")
|
||||||
66
user_manage_api/app/core/models.py
Normal file
66
user_manage_api/app/core/models.py
Normal 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
|
||||||
21
user_manage_api/app/factory.py
Normal file
21
user_manage_api/app/factory.py
Normal 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
|
||||||
33
user_manage_api/app/main.py
Normal file
33
user_manage_api/app/main.py
Normal 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()
|
||||||
1
user_manage_api/app/providers/__init__.py
Normal file
1
user_manage_api/app/providers/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
54
user_manage_api/app/providers/base.py
Normal file
54
user_manage_api/app/providers/base.py
Normal 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
|
||||||
122
user_manage_api/app/providers/cli_provider.py
Normal file
122
user_manage_api/app/providers/cli_provider.py
Normal 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]
|
||||||
1
user_manage_api/app/services/__init__.py
Normal file
1
user_manage_api/app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
174
user_manage_api/app/services/user_group_service.py
Normal file
174
user_manage_api/app/services/user_group_service.py
Normal 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))]
|
||||||
12
user_manage_api/app/state.py
Normal file
12
user_manage_api/app/state.py
Normal 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
|
||||||
30
user_manage_api/examples/python_minimal_example.py
Normal file
30
user_manage_api/examples/python_minimal_example.py
Normal 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()
|
||||||
18
user_manage_api/logs/user_manage_api.jsonl
Normal file
18
user_manage_api/logs/user_manage_api.jsonl
Normal 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}
|
||||||
18
user_manage_api/logs/user_manage_api.log
Normal file
18
user_manage_api/logs/user_manage_api.log
Normal 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=
|
||||||
6
user_manage_api/requirements.txt
Normal file
6
user_manage_api/requirements.txt
Normal 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
|
||||||
60
user_manage_api/scripts/generate_python_sdk.py
Normal file
60
user_manage_api/scripts/generate_python_sdk.py
Normal 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()
|
||||||
32
user_manage_api/tests/hash.py
Normal file
32
user_manage_api/tests/hash.py
Normal 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)
|
||||||
267
user_manage_api/tests/test_api_integration.py
Normal file
267
user_manage_api/tests/test_api_integration.py
Normal 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
|
||||||
113
user_manage_api/tests/test_service_unit.py
Normal file
113
user_manage_api/tests/test_service_unit.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user