feat(users): add API token CRUD cmdlets (closes #1)

Implement Get-PveApiToken, New-PveApiToken, and Remove-PveApiToken to
enable automated token management without manual Proxmox VE UI interaction.
Adds PveApiToken model, UserService methods for the token endpoints, and
31 Pester unit tests covering parameters, ShouldProcess, and no-session behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Clint Branham
2026-03-18 08:25:51 -05:00
parent 5b0c81a1a8
commit 94b0fcdad2
7 changed files with 584 additions and 0 deletions
@@ -0,0 +1,76 @@
using System;
using System.Text.Json.Serialization;
using Newtonsoft.Json;
namespace PSProxmoxVE.Core.Models.Users;
/// <summary>
/// Represents a Proxmox VE API token as returned by the /access/users/{userid}/token endpoints.
/// </summary>
public class PveApiToken
{
/// <summary>
/// The user ID that owns this token (e.g., "admin@pam").
/// Not returned by the API — populated by the cmdlet from the request path.
/// </summary>
[JsonPropertyName("userid")]
[JsonProperty("userid")]
public string UserId { get; set; } = string.Empty;
/// <summary>
/// The token identifier (the part after "!"), e.g., "automation".
/// </summary>
[JsonPropertyName("tokenid")]
[JsonProperty("tokenid")]
public string TokenId { get; set; } = string.Empty;
/// <summary>
/// The full token identifier in "user@realm!tokenid" format, e.g., "admin@pam!automation".
/// Only populated by New-PveApiToken; not returned by the list/get endpoints.
/// </summary>
[JsonPropertyName("full-tokenid")]
[JsonProperty("full-tokenid")]
public string? FullTokenId { get; set; }
/// <summary>
/// The token secret UUID. <b>Only present on creation</b> — store it immediately,
/// as it cannot be retrieved again.
/// </summary>
[JsonPropertyName("value")]
[JsonProperty("value")]
public string? Value { get; set; }
/// <summary>
/// Optional comment or description for this token.
/// </summary>
[JsonPropertyName("comment")]
[JsonProperty("comment")]
public string? Comment { get; set; }
/// <summary>
/// Token expiry as a Unix timestamp. 0 or null means the token never expires.
/// </summary>
[JsonPropertyName("expire")]
[JsonProperty("expire")]
public long? Expire { get; set; }
/// <summary>
/// Whether privilege separation is enabled (1) or disabled (0).
/// When enabled, the token's permissions are the intersection of the user's ACLs and
/// any explicit ACLs granted to the token itself.
/// </summary>
[JsonPropertyName("privsep")]
[JsonProperty("privsep")]
public int? PrivilegeSeparation { get; set; }
/// <inheritdoc />
public override string ToString()
{
var full = FullTokenId ?? $"{UserId}!{TokenId}";
var privsep = PrivilegeSeparation is 1 ? "privsep" : "no-privsep";
var expireStr = Expire is > 0
? DateTimeOffset.FromUnixTimeSeconds(Expire.Value).ToString("yyyy-MM-dd")
: "never";
return $"Token: {full} | {privsep} | Expires: {expireStr}";
}
}
@@ -98,6 +98,82 @@ namespace PSProxmoxVE.Core.Services
client.PutAsync($"access/users/{encodedId}", formData).GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// API Tokens
// -------------------------------------------------------------------------
/// <summary>Returns all API tokens for the specified user.</summary>
public PveApiToken[] GetApiTokens(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}/token").GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
var tokens = data?.ToObject<PveApiToken[]>() ?? Array.Empty<PveApiToken>();
foreach (var t in tokens)
t.UserId = userId;
return tokens;
}
/// <summary>
/// Creates a new API token for the specified user and returns the token object,
/// including the secret <c>Value</c> (shown only once).
/// </summary>
/// <param name="userId">User ID in "username@realm" format.</param>
/// <param name="tokenId">Token identifier (alphanumeric and hyphens).</param>
/// <param name="comment">Optional description.</param>
/// <param name="expire">Expiry as a Unix timestamp; 0 = never.</param>
/// <param name="privilegeSeparation">
/// When true, the token's effective permissions are intersected with the user's ACLs.
/// </param>
public PveApiToken CreateApiToken(
PveSession session,
string userId,
string tokenId,
string? comment = null,
long? expire = null,
bool? privilegeSeparation = null)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
if (string.IsNullOrWhiteSpace(tokenId)) throw new ArgumentNullException(nameof(tokenId));
var formData = new Dictionary<string, string>();
if (!string.IsNullOrEmpty(comment)) formData["comment"] = comment!;
if (expire.HasValue) formData["expire"] = expire.Value.ToString();
if (privilegeSeparation.HasValue) formData["privsep"] = privilegeSeparation.Value ? "1" : "0";
using var client = new PveHttpClient(session);
var encodedUser = Uri.EscapeDataString(userId);
var encodedToken = Uri.EscapeDataString(tokenId);
var response = client.PostAsync(
$"access/users/{encodedUser}/token/{encodedToken}", formData)
.GetAwaiter().GetResult();
var data = JObject.Parse(response)["data"];
var token = data?.ToObject<PveApiToken>() ?? new PveApiToken();
token.UserId = userId;
token.TokenId = tokenId;
return token;
}
/// <summary>Removes an API token.</summary>
public void RemoveApiToken(PveSession session, string userId, string tokenId)
{
if (session == null) throw new ArgumentNullException(nameof(session));
if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentNullException(nameof(userId));
if (string.IsNullOrWhiteSpace(tokenId)) throw new ArgumentNullException(nameof(tokenId));
using var client = new PveHttpClient(session);
var encodedUser = Uri.EscapeDataString(userId);
var encodedToken = Uri.EscapeDataString(tokenId);
client.DeleteAsync($"access/users/{encodedUser}/token/{encodedToken}")
.GetAwaiter().GetResult();
}
// -------------------------------------------------------------------------
// Roles
// -------------------------------------------------------------------------