diff --git a/src/PSProxmoxVE.Core/Models/Users/PveApiToken.cs b/src/PSProxmoxVE.Core/Models/Users/PveApiToken.cs new file mode 100644 index 0000000..f526436 --- /dev/null +++ b/src/PSProxmoxVE.Core/Models/Users/PveApiToken.cs @@ -0,0 +1,76 @@ +using System; +using System.Text.Json.Serialization; +using Newtonsoft.Json; + +namespace PSProxmoxVE.Core.Models.Users; + +/// +/// Represents a Proxmox VE API token as returned by the /access/users/{userid}/token endpoints. +/// +public class PveApiToken +{ + /// + /// The user ID that owns this token (e.g., "admin@pam"). + /// Not returned by the API — populated by the cmdlet from the request path. + /// + [JsonPropertyName("userid")] + [JsonProperty("userid")] + public string UserId { get; set; } = string.Empty; + + /// + /// The token identifier (the part after "!"), e.g., "automation". + /// + [JsonPropertyName("tokenid")] + [JsonProperty("tokenid")] + public string TokenId { get; set; } = string.Empty; + + /// + /// 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. + /// + [JsonPropertyName("full-tokenid")] + [JsonProperty("full-tokenid")] + public string? FullTokenId { get; set; } + + /// + /// The token secret UUID. Only present on creation — store it immediately, + /// as it cannot be retrieved again. + /// + [JsonPropertyName("value")] + [JsonProperty("value")] + public string? Value { get; set; } + + /// + /// Optional comment or description for this token. + /// + [JsonPropertyName("comment")] + [JsonProperty("comment")] + public string? Comment { get; set; } + + /// + /// Token expiry as a Unix timestamp. 0 or null means the token never expires. + /// + [JsonPropertyName("expire")] + [JsonProperty("expire")] + public long? Expire { get; set; } + + /// + /// 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. + /// + [JsonPropertyName("privsep")] + [JsonProperty("privsep")] + public int? PrivilegeSeparation { get; set; } + + /// + 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}"; + } +} diff --git a/src/PSProxmoxVE.Core/Services/UserService.cs b/src/PSProxmoxVE.Core/Services/UserService.cs index 4191ca8..bdbe957 100644 --- a/src/PSProxmoxVE.Core/Services/UserService.cs +++ b/src/PSProxmoxVE.Core/Services/UserService.cs @@ -98,6 +98,82 @@ namespace PSProxmoxVE.Core.Services client.PutAsync($"access/users/{encodedId}", formData).GetAwaiter().GetResult(); } + // ------------------------------------------------------------------------- + // API Tokens + // ------------------------------------------------------------------------- + + /// Returns all API tokens for the specified user. + 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() ?? Array.Empty(); + foreach (var t in tokens) + t.UserId = userId; + return tokens; + } + + /// + /// Creates a new API token for the specified user and returns the token object, + /// including the secret Value (shown only once). + /// + /// User ID in "username@realm" format. + /// Token identifier (alphanumeric and hyphens). + /// Optional description. + /// Expiry as a Unix timestamp; 0 = never. + /// + /// When true, the token's effective permissions are intersected with the user's ACLs. + /// + 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(); + 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() ?? new PveApiToken(); + token.UserId = userId; + token.TokenId = tokenId; + return token; + } + + /// Removes an API token. + 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 // ------------------------------------------------------------------------- diff --git a/src/PSProxmoxVE/Cmdlets/Users/GetPveApiTokenCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/GetPveApiTokenCmdlet.cs new file mode 100644 index 0000000..3a0d320 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Users/GetPveApiTokenCmdlet.cs @@ -0,0 +1,47 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Users; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Users +{ + /// + /// Lists API tokens for a Proxmox VE user. + /// + /// 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. + /// + /// + [Cmdlet(VerbsCommon.Get, "PveApiToken")] + [OutputType(typeof(PveApiToken))] + public class GetPveApiTokenCmdlet : PveCmdletBase + { + /// + /// The user ID whose tokens to list, in "username@realm" format (e.g., "admin@pam"). + /// Accepts pipeline input from Get-PveUser (PveUser.UserId). + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string UserId { get; set; } = string.Empty; + + /// Filter to a specific token identifier (e.g., "automation"). + [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); + } + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Users/NewPveApiTokenCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/NewPveApiTokenCmdlet.cs new file mode 100644 index 0000000..ce4bb01 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Users/NewPveApiTokenCmdlet.cs @@ -0,0 +1,68 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Models.Users; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Users +{ + /// + /// Creates a new API token for a Proxmox VE user. + /// + /// 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. + /// + /// + [Cmdlet(VerbsCommon.New, "PveApiToken", SupportsShouldProcess = true)] + [OutputType(typeof(PveApiToken))] + public class NewPveApiTokenCmdlet : PveCmdletBase + { + /// + /// The user ID to create the token for, in "username@realm" format (e.g., "admin@pam"). + /// Accepts pipeline input from Get-PveUser (PveUser.UserId). + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string UserId { get; set; } = string.Empty; + + /// The token identifier (alphanumeric, hyphens allowed; e.g., "automation"). + [Parameter(Mandatory = true, Position = 1)] + public string TokenId { get; set; } = string.Empty; + + /// Optional description for this token. + [Parameter(Mandatory = false)] + public string? Comment { get; set; } + + /// + /// Token expiry as a Unix timestamp. Use 0 or omit for no expiry. + /// + [Parameter(Mandatory = false)] + public long? Expire { get; set; } + + /// + /// 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. + /// + [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); + } + } +} diff --git a/src/PSProxmoxVE/Cmdlets/Users/RemovePveApiTokenCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Users/RemovePveApiTokenCmdlet.cs new file mode 100644 index 0000000..d065f76 --- /dev/null +++ b/src/PSProxmoxVE/Cmdlets/Users/RemovePveApiTokenCmdlet.cs @@ -0,0 +1,42 @@ +using System.Management.Automation; +using PSProxmoxVE.Core.Services; + +namespace PSProxmoxVE.Cmdlets.Users +{ + /// + /// Removes an API token from a Proxmox VE user. + /// + /// Permanently deletes the specified API token. Any automation using this token will + /// immediately lose access. This operation cannot be undone. + /// + /// + [Cmdlet(VerbsCommon.Remove, "PveApiToken", SupportsShouldProcess = true, + ConfirmImpact = ConfirmImpact.High)] + public class RemovePveApiTokenCmdlet : PveCmdletBase + { + /// + /// The user ID that owns the token, in "username@realm" format (e.g., "admin@pam"). + /// Accepts pipeline input from Get-PveApiToken (PveApiToken.UserId). + /// + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true)] + public string UserId { get; set; } = string.Empty; + + /// + /// The token identifier to remove (e.g., "automation"). + /// Accepts pipeline input from Get-PveApiToken (PveApiToken.TokenId). + /// + [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); + } + } +} diff --git a/src/PSProxmoxVE/PSProxmoxVE.psd1 b/src/PSProxmoxVE/PSProxmoxVE.psd1 index 87e5dd9..87d4f14 100644 --- a/src/PSProxmoxVE/PSProxmoxVE.psd1 +++ b/src/PSProxmoxVE/PSProxmoxVE.psd1 @@ -132,6 +132,11 @@ 'Get-PvePermission', 'Set-PvePermission', + # API Tokens + 'Get-PveApiToken', + 'New-PveApiToken', + 'Remove-PveApiToken', + # Templates 'Get-PveTemplate', 'New-PveTemplate', diff --git a/tests/PSProxmoxVE.Tests/Users/ApiTokenCmdlets.Tests.ps1 b/tests/PSProxmoxVE.Tests/Users/ApiTokenCmdlets.Tests.ps1 new file mode 100644 index 0000000..f91e80a --- /dev/null +++ b/tests/PSProxmoxVE.Tests/Users/ApiTokenCmdlets.Tests.ps1 @@ -0,0 +1,270 @@ +#Requires -Module Pester +<# +.SYNOPSIS + Pester 5 tests for API token cmdlets: + Get-PveApiToken, New-PveApiToken, Remove-PveApiToken. + + All tests are fully offline — no live Proxmox VE target is required. + If a cmdlet is not yet compiled the test is marked Skipped. +#> + +BeforeAll { + $moduleRoot = Resolve-Path (Join-Path $PSScriptRoot '../../../src/PSProxmoxVE') + $dllCandidates = @( + Join-Path $moduleRoot 'bin/Debug/net9.0/PSProxmoxVE.dll' + Join-Path $moduleRoot 'bin/Release/net9.0/PSProxmoxVE.dll' + Join-Path $moduleRoot 'bin/Debug/net48/PSProxmoxVE.dll' + Join-Path $moduleRoot 'bin/Release/net48/PSProxmoxVE.dll' + ) + + $script:ModuleDll = $dllCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + + if ($null -eq $script:ModuleDll) { + throw "PSProxmoxVE.dll not found. Build the project before running Pester tests." + } + + Import-Module $script:ModuleDll -Force -ErrorAction Stop + + $allNames = @('Get-PveApiToken', 'New-PveApiToken', 'Remove-PveApiToken') + + $script:Availability = @{} + foreach ($name in $allNames) { + $script:Availability[$name] = $null -ne (Get-Command $name -ErrorAction SilentlyContinue) + } + + function Skip-IfMissing([string]$Name) { + if (-not $script:Availability[$Name]) { + Set-ItResult -Skipped -Because "$Name is not yet implemented in this build" + } + } +} + +# --------------------------------------------------------------------------- +# Manifest contract +# --------------------------------------------------------------------------- +Describe 'API Token cmdlets — manifest declarations' { + BeforeAll { + $manifestPath = Join-Path $moduleRoot 'PSProxmoxVE.psd1' + $script:Manifest = if (Test-Path $manifestPath) { Import-PowerShellDataFile $manifestPath } else { $null } + } + + It " should be declared in CmdletsToExport" -TestCases @( + @{ cmdName = 'Get-PveApiToken' } + @{ cmdName = 'New-PveApiToken' } + @{ cmdName = 'Remove-PveApiToken' } + ) { + if ($null -eq $script:Manifest) { Set-ItResult -Skipped -Because 'Manifest not found'; return } + $script:Manifest.CmdletsToExport | Should -Contain $cmdName + } +} + +# --------------------------------------------------------------------------- +# Get-PveApiToken +# --------------------------------------------------------------------------- +Describe 'Get-PveApiToken' { + + BeforeAll { $script:Cmd = Get-Command 'Get-PveApiToken' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Get-PveApiToken' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Get-PveApiToken' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'Parameter metadata' { + It 'Should have UserId as mandatory parameter' { + Skip-IfMissing 'Get-PveApiToken' + $isMandatory = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'UserId should accept pipeline input by property name' { + Skip-IfMissing 'Get-PveApiToken' + $byPropName = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.ValueFromPipelineByPropertyName } + $byPropName | Should -Not -BeNullOrEmpty + } + + It 'Should have TokenId as optional filter parameter' { + Skip-IfMissing 'Get-PveApiToken' + $script:Cmd.Parameters.ContainsKey('TokenId') | Should -BeTrue + } + + It 'TokenId should not be Mandatory' { + Skip-IfMissing 'Get-PveApiToken' + $isMandatory = $script:Cmd.Parameters['TokenId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -BeNullOrEmpty + } + + It 'Should have Session parameter' { + Skip-IfMissing 'Get-PveApiToken' + $script:Cmd.Parameters.ContainsKey('Session') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Get-PveApiToken' + { Get-PveApiToken -UserId 'admin@pam' -ErrorAction Stop } | Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# New-PveApiToken +# --------------------------------------------------------------------------- +Describe 'New-PveApiToken' { + + BeforeAll { $script:Cmd = Get-Command 'New-PveApiToken' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'ShouldProcess support' { + It 'Should support WhatIf' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'WhatIf should not throw even without a session' { + Skip-IfMissing 'New-PveApiToken' + { New-PveApiToken -UserId 'admin@pam' -TokenId 'test' -WhatIf } | Should -Not -Throw + } + } + + Context 'Required parameters' { + It 'UserId should be Mandatory' { + Skip-IfMissing 'New-PveApiToken' + $isMandatory = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'UserId should accept pipeline input by property name' { + Skip-IfMissing 'New-PveApiToken' + $byPropName = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.ValueFromPipelineByPropertyName } + $byPropName | Should -Not -BeNullOrEmpty + } + + It 'TokenId should be Mandatory' { + Skip-IfMissing 'New-PveApiToken' + $isMandatory = $script:Cmd.Parameters['TokenId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + } + + Context 'Optional parameters' { + It 'Should have Comment parameter' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.Parameters.ContainsKey('Comment') | Should -BeTrue + } + + It 'Should have Expire parameter' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.Parameters.ContainsKey('Expire') | Should -BeTrue + } + + It 'Should have PrivilegeSeparation switch' { + Skip-IfMissing 'New-PveApiToken' + $script:Cmd.Parameters.ContainsKey('PrivilegeSeparation') | Should -BeTrue + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'New-PveApiToken' + { New-PveApiToken -UserId 'admin@pam' -TokenId 'test' -Confirm:$false -ErrorAction Stop } | Should -Throw '*No active Proxmox VE session*' + } + } +} + +# --------------------------------------------------------------------------- +# Remove-PveApiToken +# --------------------------------------------------------------------------- +Describe 'Remove-PveApiToken' { + + BeforeAll { $script:Cmd = Get-Command 'Remove-PveApiToken' -ErrorAction SilentlyContinue } + + Context 'Command existence' { + It 'Should be available after module import' { + Skip-IfMissing 'Remove-PveApiToken' + $script:Cmd | Should -Not -BeNullOrEmpty + } + + It 'Should be a CmdletInfo (binary cmdlet)' { + Skip-IfMissing 'Remove-PveApiToken' + $script:Cmd.CommandType | Should -Be 'Cmdlet' + } + } + + Context 'ShouldProcess / ConfirmImpact' { + It 'Should support WhatIf' { + Skip-IfMissing 'Remove-PveApiToken' + $script:Cmd.Parameters.ContainsKey('WhatIf') | Should -BeTrue + } + + It 'Should declare ConfirmImpact High' { + Skip-IfMissing 'Remove-PveApiToken' + $attr = $script:Cmd.ImplementingType.GetCustomAttributes( + [System.Management.Automation.CmdletAttribute], $false) | + Select-Object -First 1 + $attr.ConfirmImpact | Should -Be ([System.Management.Automation.ConfirmImpact]::High) + } + } + + Context 'Required parameters' { + It 'UserId should be Mandatory' { + Skip-IfMissing 'Remove-PveApiToken' + $isMandatory = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'UserId should accept pipeline input by property name' { + Skip-IfMissing 'Remove-PveApiToken' + $byPropName = $script:Cmd.Parameters['UserId'].ParameterSets.Values | + Where-Object { $_.ValueFromPipelineByPropertyName } + $byPropName | Should -Not -BeNullOrEmpty + } + + It 'TokenId should be Mandatory' { + Skip-IfMissing 'Remove-PveApiToken' + $isMandatory = $script:Cmd.Parameters['TokenId'].ParameterSets.Values | + Where-Object { $_.IsMandatory } + $isMandatory | Should -Not -BeNullOrEmpty + } + + It 'TokenId should accept pipeline input by property name' { + Skip-IfMissing 'Remove-PveApiToken' + $byPropName = $script:Cmd.Parameters['TokenId'].ParameterSets.Values | + Where-Object { $_.ValueFromPipelineByPropertyName } + $byPropName | Should -Not -BeNullOrEmpty + } + } + + Context 'Without active session' { + It 'Should throw when no session is active' { + Skip-IfMissing 'Remove-PveApiToken' + { Remove-PveApiToken -UserId 'admin@pam' -TokenId 'test' -Confirm:$false -ErrorAction Stop } | Should -Throw '*No active Proxmox VE session*' + } + } +}