feat(services): implement service layer for all domains

Add service classes for nodes, VMs, containers, storage, snapshots,
network/SDN, users/roles/permissions, templates, cloud-init, tasks,
and cluster. SDN services enforce PVE 8.0+ version guard. Task service
supports polling with configurable timeout and progress callbacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-17 15:45:23 -05:00
parent 4ac38f7714
commit 52f1978de4
11 changed files with 1705 additions and 0 deletions
@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Client;
using PSProxmoxVE.Core.Models.Users;
namespace PSProxmoxVE.Core.Services
{
/// <summary>
/// Service for Proxmox VE access control — users, roles, and permissions (ACLs).
/// </summary>
public class UserService
{
// -------------------------------------------------------------------------
// Users
// -------------------------------------------------------------------------
/// <summary>Returns all users.</summary>
public PveUser[] GetUsers(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
using var client = new PveHttpClient(session);
var response = client.GetAsync("access/users").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveUser[]>() ?? Array.Empty<PveUser>();
}
/// <summary>Returns a single user by their user ID (e.g. "admin@pam").</summary>
public PveUser GetUser(PveSession session, string userId)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
using var client = new PveHttpClient(session);
var encodedId = Uri.EscapeDataString(userId);
var response = client.GetAsync($"access/users/{encodedId}").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
var user = data?.ToObject<PveUser>() ?? new PveUser();
// The single-user endpoint may not echo back the userid
if (string.IsNullOrEmpty(user.UserId))
user.UserId = userId;
return user;
}
/// <summary>
/// Creates a new user account.
/// </summary>
/// <param name="userId">User ID in "username@realm" format.</param>
/// <param name="config">Additional fields (password, email, firstname, lastname, etc.).</param>
public void CreateUser(
PveSession session,
string userId,
Dictionary<string, object>? config = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
var formData = new Dictionary<string, string> { ["userid"] = userId };
if (config != null)
{
foreach (var kvp in config)
formData[kvp.Key] = kvp.Value?.ToString() ?? string.Empty;
}
using var client = new PveHttpClient(session);
client.PostAsync("access/users", formData).GetAwaiter().GetResult();
}
/// <summary>Removes a user account.</summary>
public void RemoveUser(PveSession session, string userId)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
using var client = new PveHttpClient(session);
var encodedId = Uri.EscapeDataString(userId);
client.DeleteAsync($"access/users/{encodedId}").GetAwaiter().GetResult();
}
/// <summary>Updates one or more properties of an existing user.</summary>
public void SetUser(
PveSession session,
string userId,
Dictionary<string, object> config)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
if (config == null) throw new ArgumentNullException(nameof(config));
using var client = new PveHttpClient(session);
var encodedId = Uri.EscapeDataString(userId);
var formData = config.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? string.Empty);
client.PutAsync($"access/users/{encodedId}", formData).GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Roles
// -------------------------------------------------------------------------
/// <summary>Returns all roles.</summary>
public PveRole[] GetRoles(PveSession session)
{
if (session == null) throw new ArgumentNullException(nameof(session));
using var client = new PveHttpClient(session);
var response = client.GetAsync("access/roles").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
return data?.ToObject<PveRole[]>() ?? Array.Empty<PveRole>();
}
/// <summary>Creates a new role.</summary>
/// <param name="roleId">Role name.</param>
/// <param name="privileges">Comma-separated list of privilege strings.</param>
public void CreateRole(PveSession session, string roleId, string? privileges = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(roleId)) throw new ArgumentNullException(nameof(roleId));
var formData = new Dictionary<string, string> { ["roleid"] = roleId };
if (!string.IsNullOrEmpty(privileges))
formData["privs"] = privileges!;
using var client = new PveHttpClient(session);
client.PostAsync("access/roles", formData).GetAwaiter().GetResult();
}
/// <summary>Removes a role.</summary>
public void RemoveRole(PveSession session, string roleId)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(roleId)) throw new ArgumentNullException(nameof(roleId));
using var client = new PveHttpClient(session);
client.DeleteAsync($"access/roles/{Uri.EscapeDataString(roleId)}")
.GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Permissions / ACLs
// -------------------------------------------------------------------------
/// <summary>
/// Returns the effective permissions (resolved privilege set) for a user or token.
/// </summary>
/// <param name="userId">Optional user ID to query; defaults to the authenticated user.</param>
/// <param name="path">Optional path to restrict the query.</param>
public PvePermission[] GetPermissions(
PveSession session,
string? userId = null,
string? path = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
var resource = "access/permissions";
var queryParts = new List<string>();
if (!string.IsNullOrEmpty(userId))
queryParts.Add($"userid={Uri.EscapeDataString(userId!)}");
if (!string.IsNullOrEmpty(path))
queryParts.Add($"path={Uri.EscapeDataString(path!)}");
if (queryParts.Count > 0)
resource += "?" + string.Join("&", queryParts);
using var client = new PveHttpClient(session);
var response = client.GetAsync(resource).GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
// /access/permissions returns an object keyed by path, not an array
if (data == null) return Array.Empty<PvePermission>();
if (data.Type == JTokenType.Array)
return data.ToObject<PvePermission[]>() ?? Array.Empty<PvePermission>();
// Unwrap path-keyed object into flat list
var result = new List<PvePermission>();
foreach (var prop in ((JObject)data).Properties())
{
var perm = new PvePermission { Path = prop.Name };
result.Add(perm);
}
return result.ToArray();
}
/// <summary>
/// Sets (adds or updates) an ACL entry.
/// </summary>
/// <param name="path">Access control path (e.g. "/", "/nodes/pve", "/vms/100").</param>
/// <param name="roles">Comma-separated role IDs.</param>
/// <param name="users">Comma-separated user IDs.</param>
/// <param name="groups">Comma-separated group names.</param>
/// <param name="propagate">Whether to propagate the permission to sub-paths.</param>
/// <param name="delete">If true, removes the specified ACL entries.</param>
public void SetPermission(
PveSession session,
string path,
string roles,
string? users = null,
string? groups = null,
bool propagate = true,
bool delete = false)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path));
if (string.IsNullOrWhiteSpace(roles)) throw new ArgumentNullException(nameof(roles));
var formData = new Dictionary<string, string>
{
["path"] = path,
["roles"] = roles,
["propagate"] = propagate ? "1" : "0",
["delete"] = delete ? "1" : "0"
};
if (!string.IsNullOrEmpty(users)) formData["users"] = users!;
if (!string.IsNullOrEmpty(groups)) formData["groups"] = groups!;
using var client = new PveHttpClient(session);
client.PutAsync("access/acl", formData).GetAwaiter().GetResult();
}
}
}