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
// -------------------------------------------------------------------------
@@ -0,0 +1,47 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Users;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Users
{
/// <summary>
/// <para type="synopsis">Lists API tokens for a Proxmox VE user.</para>
/// <para type="description">
/// Returns the API tokens associated with the specified user. When -TokenId is omitted
/// all tokens for the user are returned. Note: the token secret (Value) is never
/// returned by the GET endpoints — it is only available immediately after creation
/// via New-PveApiToken.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Get, "PveApiToken")]
[OutputType(typeof(PveApiToken))]
public class GetPveApiTokenCmdlet : PveCmdletBase
{
/// <summary>
/// The user ID whose tokens to list, in "username@realm" format (e.g., "admin@pam").
/// Accepts pipeline input from Get-PveUser (PveUser.UserId).
/// </summary>
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string UserId { get; set; } = string.Empty;
/// <summary>Filter to a specific token identifier (e.g., "automation").</summary>
[Parameter(Mandatory = false, Position = 1)]
public string? TokenId { get; set; }
protected override void ProcessRecord()
{
var session = GetSession();
var service = new UserService();
var tokens = service.GetApiTokens(session, UserId);
foreach (var token in tokens)
{
if (!string.IsNullOrEmpty(TokenId) &&
!string.Equals(token.TokenId, TokenId, System.StringComparison.OrdinalIgnoreCase))
continue;
WriteObject(token);
}
}
}
}
@@ -0,0 +1,68 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Models.Users;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Users
{
/// <summary>
/// <para type="synopsis">Creates a new API token for a Proxmox VE user.</para>
/// <para type="description">
/// Generates a new API token and returns a PveApiToken object containing both the
/// FullTokenId (e.g., "admin@pam!automation") and the token secret in the Value
/// property. The secret is shown only once — save it immediately. Use the FullTokenId
/// and Value together as the API token credential for Connect-PveServer.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.New, "PveApiToken", SupportsShouldProcess = true)]
[OutputType(typeof(PveApiToken))]
public class NewPveApiTokenCmdlet : PveCmdletBase
{
/// <summary>
/// The user ID to create the token for, in "username@realm" format (e.g., "admin@pam").
/// Accepts pipeline input from Get-PveUser (PveUser.UserId).
/// </summary>
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string UserId { get; set; } = string.Empty;
/// <summary>The token identifier (alphanumeric, hyphens allowed; e.g., "automation").</summary>
[Parameter(Mandatory = true, Position = 1)]
public string TokenId { get; set; } = string.Empty;
/// <summary>Optional description for this token.</summary>
[Parameter(Mandatory = false)]
public string? Comment { get; set; }
/// <summary>
/// Token expiry as a Unix timestamp. Use 0 or omit for no expiry.
/// </summary>
[Parameter(Mandatory = false)]
public long? Expire { get; set; }
/// <summary>
/// When specified, privilege separation is enabled: the token's effective permissions
/// are the intersection of the user's ACLs and any explicit ACLs granted to the token.
/// When omitted, the token inherits the full permissions of its user.
/// </summary>
[Parameter(Mandatory = false)]
public SwitchParameter PrivilegeSeparation { get; set; }
protected override void ProcessRecord()
{
var fullTokenId = $"{UserId}!{TokenId}";
if (!ShouldProcess(fullTokenId, "Create PVE API Token"))
return;
var session = GetSession();
var service = new UserService();
var token = service.CreateApiToken(
session,
UserId,
TokenId,
comment: Comment,
expire: Expire,
privilegeSeparation: PrivilegeSeparation.IsPresent ? true : (bool?)null);
WriteObject(token);
}
}
}
@@ -0,0 +1,42 @@
using System.Management.Automation;
using PSProxmoxVE.Core.Services;
namespace PSProxmoxVE.Cmdlets.Users
{
/// <summary>
/// <para type="synopsis">Removes an API token from a Proxmox VE user.</para>
/// <para type="description">
/// Permanently deletes the specified API token. Any automation using this token will
/// immediately lose access. This operation cannot be undone.
/// </para>
/// </summary>
[Cmdlet(VerbsCommon.Remove, "PveApiToken", SupportsShouldProcess = true,
ConfirmImpact = ConfirmImpact.High)]
public class RemovePveApiTokenCmdlet : PveCmdletBase
{
/// <summary>
/// The user ID that owns the token, in "username@realm" format (e.g., "admin@pam").
/// Accepts pipeline input from Get-PveApiToken (PveApiToken.UserId).
/// </summary>
[Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)]
public string UserId { get; set; } = string.Empty;
/// <summary>
/// The token identifier to remove (e.g., "automation").
/// Accepts pipeline input from Get-PveApiToken (PveApiToken.TokenId).
/// </summary>
[Parameter(Mandatory = true, Position = 1, ValueFromPipelineByPropertyName = true)]
public string TokenId { get; set; } = string.Empty;
protected override void ProcessRecord()
{
var session = GetSession();
if (!ShouldProcess($"{UserId}!{TokenId}", "Remove PVE API Token"))
return;
var service = new UserService();
service.RemoveApiToken(session, UserId, TokenId);
}
}
}
+5
View File
@@ -132,6 +132,11 @@
'Get-PvePermission',
'Set-PvePermission',
# API Tokens
'Get-PveApiToken',
'New-PveApiToken',
'Remove-PveApiToken',
# Templates
'Get-PveTemplate',
'New-PveTemplate',