2 Commits

Author SHA1 Message Date
Joseph Doherty 7288f39f5d Add Set-HistorianCredentials.ps1 for DPAPI-encrypted credential persistence
Drops a small helper for the gated live-integration tests that need
HISTORIAN_USER + HISTORIAN_PASSWORD set in the process environment
(currently GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian).

Three modes:
  - Default: prompts for username (default <COMPUTERNAME>\<USERNAME>) and
    password (silent), saves to %USERPROFILE%\.histsdk\credentials.xml via
    Export-Clixml. The SecureString inside the PSCredential is DPAPI-encrypted
    and decryptable only by the same Windows user account on the same machine.
  - -Load: reads the saved credential and exports HISTORIAN_USER +
    HISTORIAN_PASSWORD into the current PowerShell session's environment.
  - -Clear: deletes the saved credential file.

Also accepts -Path to override the storage location (e.g. for keeping
multiple credential sets side by side) and -UserName to skip the username
prompt for password-only re-saves.

Stored under the user profile, never inside the repo, so it cannot be
committed accidentally. The file format is plain Export-Clixml — no custom
encoding shenanigans.

Live-verified locally: -Load + dotnet test passes the previously-skipped
GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian test against
the local Historian with IntegratedSecurity=false.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:22:34 -04:00
Joseph Doherty f32fd57874 Remove dead dialect methods; unblock explicit-creds tag-metadata path
Two cleanups from the post-EnsureTagAsync punch list — both isolated, no
protocol discovery required.

#89 dead code in Historian2020ProtocolDialect:
  - BrowseTagNamesAsync and GetTagMetadataAsync on the dialect both threw
    ProtocolEvidenceMissingException, but HistorianClient routes those calls
    directly to HistorianWcfTagClient — the dialect overrides were never
    reached. Removed both methods. ReadBlocksAsync stays (it's a deliberate
    guardrailed entry on the public surface).

#90 explicit-creds tag-metadata path:
  - HistorianWcfTagClient.WcfRetrievalSession.ValidateSupportedAuth threw
    ProtocolEvidenceMissingException whenever IntegratedSecurity=false AND
    UserName/Password were supplied. But the surrounding code already wires
    those creds through ApplyWindowsCredential ->
    factory.Credentials.Windows.ClientCredential — the validator was just
    being conservative about an untested combination.
  - Inverted the check: now only rejects the no-auth-at-all combination
    (IntegratedSecurity=false + no UserName + no Password). The other three
    valid auth shapes pass through to WCF.

Tests: 161 -> 163 (+2). New unit test verifies the no-auth case still
throws; new gated live integration test
GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian exercises the
explicit-creds path when HISTORIAN_USER+HISTORIAN_PASSWORD are set, skips
cleanly otherwise.

CLAUDE.md updated: removed the two now-resolved entries from "Remaining
gaps"; explicit-creds line refined to note the live-verification env-var
requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:04:51 -04:00
5 changed files with 177 additions and 15 deletions
+1 -2
View File
@@ -94,8 +94,7 @@ End-to-end chain working from a pure managed .NET 10 client: `Hist.GetV → Hist
Smaller, isolated items — none block the production read surface: Smaller, isolated items — none block the production read surface:
- Remote TCP transports (`RemoteTcpIntegrated`, `RemoteTcpCertificate`) untested against an actual remote Historian (tests skip without `HISTORIAN_REMOTE_TCP_HOST`). - Remote TCP transports (`RemoteTcpIntegrated`, `RemoteTcpCertificate`) untested against an actual remote Historian (tests skip without `HISTORIAN_REMOTE_TCP_HOST`).
- Explicit username/password tag-metadata path (`HistorianWcfTagClient` line ~357) throws — only integrated security wired for that op. - Explicit username/password tag-metadata path is wired (validator only blocks no-auth-at-all), but live-verification requires `HISTORIAN_USER`+`HISTORIAN_PASSWORD` set; gated test `GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian` skips otherwise.
- `Historian2020ProtocolDialect.GetTagInfoByName/GetTagInfos` throws — currently dead code; `GetTagMetadataAsync` works through the WCF tag client instead.
- Per-row trailing ~24 bytes of `GetNextQueryResultBuffer` are not decoded (likely per-sample value/source/state metadata). - Per-row trailing ~24 bytes of `GetNextQueryResultBuffer` are not decoded (likely per-sample value/source/state metadata).
- `EnsureTagAsync` distinct `MinRaw`/`MaxRaw` persistence requires `ApplyScaling=true` + a follow-up `UpdateTags` call — not yet wired (no API user has asked). - `EnsureTagAsync` distinct `MinRaw`/`MaxRaw` persistence requires `ApplyScaling=true` + a follow-up `UpdateTags` call — not yet wired (no API user has asked).
+117
View File
@@ -0,0 +1,117 @@
<#
.SYNOPSIS
Prompts for Historian credentials and persists them to an encrypted file under the
current user's profile, or loads previously-saved credentials into the current
session's HISTORIAN_USER and HISTORIAN_PASSWORD environment variables.
.DESCRIPTION
Persistence uses Windows DPAPI via Export-Clixml. The resulting XML can only be
decrypted by the same user account on the same machine. The file is saved
outside the repository (default: $env:USERPROFILE\.histsdk\credentials.xml) so
it cannot be accidentally committed.
The integration tests in this repo read HISTORIAN_USER and HISTORIAN_PASSWORD
from the process environment. Run this script with -Load before launching
`dotnet test` to inject the saved credentials into the current session.
.PARAMETER Load
Read previously-saved credentials and set $env:HISTORIAN_USER + $env:HISTORIAN_PASSWORD
in the current PowerShell session. The variables persist for the lifetime of
this session only - re-run with -Load in a new shell.
.PARAMETER Clear
Delete the saved credentials file. Subsequent -Load calls will fail until
credentials are re-saved.
.PARAMETER Path
Override the default storage path. Useful for keeping multiple credential sets
side-by-side (e.g. local-vs-remote).
.PARAMETER UserName
Skip the username prompt and use the supplied value. Password is still prompted
interactively. Convenient for scripted re-saves where only the password rotates.
.EXAMPLE
PS> .\scripts\Set-HistorianCredentials.ps1
Prompts for username + password, saves both to %USERPROFILE%\.histsdk\credentials.xml.
.EXAMPLE
PS> .\scripts\Set-HistorianCredentials.ps1 -Load
Loads the saved credentials into HISTORIAN_USER and HISTORIAN_PASSWORD for this session.
.EXAMPLE
PS> .\scripts\Set-HistorianCredentials.ps1 -Clear
Deletes the saved credentials file.
#>
[CmdletBinding(DefaultParameterSetName = 'Save')]
param(
[Parameter(ParameterSetName = 'Load')]
[switch]$Load,
[Parameter(ParameterSetName = 'Clear')]
[switch]$Clear,
[string]$Path = (Join-Path $env:USERPROFILE '.histsdk\credentials.xml'),
[Parameter(ParameterSetName = 'Save')]
[string]$UserName
)
$ErrorActionPreference = 'Stop'
function ConvertTo-PlainText {
param([Parameter(Mandatory)][securestring]$SecureString)
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) }
finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) }
}
if ($Clear) {
if (Test-Path $Path) {
Remove-Item -LiteralPath $Path -Force
Write-Host "Deleted $Path"
} else {
Write-Host "Nothing to clear (file does not exist): $Path"
}
return
}
if ($Load) {
if (-not (Test-Path $Path)) {
throw "No saved credentials at $Path. Run this script without -Load to save them first."
}
$cred = Import-Clixml -LiteralPath $Path
if ($cred -isnot [System.Management.Automation.PSCredential]) {
throw "File at $Path is not a PSCredential - re-save with this script."
}
$env:HISTORIAN_USER = $cred.UserName
$env:HISTORIAN_PASSWORD = ConvertTo-PlainText $cred.Password
Write-Host "Loaded credentials for '$($cred.UserName)' from $Path."
Write-Host "HISTORIAN_USER and HISTORIAN_PASSWORD are set for this PowerShell session."
return
}
# Save mode (default).
$dir = Split-Path -Parent $Path
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
}
if ([string]::IsNullOrWhiteSpace($UserName)) {
$suggested = "$env:COMPUTERNAME\$env:USERNAME"
$UserName = Read-Host "Historian user [$suggested]"
if ([string]::IsNullOrWhiteSpace($UserName)) {
$UserName = $suggested
}
}
$securePassword = Read-Host "Historian password for '$UserName'" -AsSecureString
$cred = New-Object System.Management.Automation.PSCredential($UserName, $securePassword)
$cred | Export-Clixml -LiteralPath $Path
Write-Host ""
Write-Host "Saved credentials for '$UserName' to $Path"
Write-Host "(DPAPI-encrypted; decryptable only by '$env:USERNAME' on '$env:COMPUTERNAME'.)"
Write-Host ""
Write-Host "Run '.\scripts\Set-HistorianCredentials.ps1 -Load' in any new session to inject"
Write-Host "the credentials into HISTORIAN_USER and HISTORIAN_PASSWORD."
@@ -87,17 +87,6 @@ internal sealed class Historian2020ProtocolDialect
#pragma warning restore CA1416 #pragma warning restore CA1416
} }
public IAsyncEnumerable<string> BrowseTagNamesAsync(string filter, CancellationToken cancellationToken)
{
return Missing<string>("StartLikeTagNameSearch/GetLikeTagnames", cancellationToken);
}
public Task<HistorianTagMetadata?> GetTagMetadataAsync(string tag, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
throw new ProtocolEvidenceMissingException("GetTagInfoByName/GetTagInfos");
}
public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken) public Task<HistorianConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -351,10 +351,18 @@ internal static class HistorianWcfTagClient
private static void ValidateSupportedAuth(HistorianClientOptions options) private static void ValidateSupportedAuth(HistorianClientOptions options)
{ {
// Three valid auth shapes:
// 1. IntegratedSecurity=true (current Windows identity, no UserName/Password)
// 2. IntegratedSecurity=false + UserName + Password (NTLM/Kerberos with explicit creds)
// 3. IntegratedSecurity=true + UserName + Password (impersonation/explicit override)
// The fourth combination — IntegratedSecurity=false with no UserName/Password — has
// no way to authenticate against the /Hist-Integrated endpoint and is rejected.
if (!options.IntegratedSecurity if (!options.IntegratedSecurity
&& (!string.IsNullOrEmpty(options.UserName) || !string.IsNullOrEmpty(options.Password))) && string.IsNullOrEmpty(options.UserName)
&& string.IsNullOrEmpty(options.Password))
{ {
throw new ProtocolEvidenceMissingException("Open2 explicit username/password tag browse"); throw new ProtocolEvidenceMissingException(
"Tag browse / metadata requires either IntegratedSecurity=true OR an explicit UserName + Password.");
} }
} }
@@ -313,6 +313,55 @@ public sealed class HistorianClientIntegrationTests
Assert.Equal(host, status.ServerName); Assert.Equal(host, status.ServerName);
} }
// The validator inside HistorianWcfTagClient now allows IntegratedSecurity=false WHEN
// explicit UserName + Password are provided (NTLM/Kerberos with non-current-user creds).
// It still rejects the no-credentials-at-all case since there's no way to authenticate
// against /Hist-Integrated.
[Fact]
public async Task GetTagMetadataAsync_NoAuthAndNoCredentials_Throws()
{
HistorianClient client = new(new HistorianClientOptions
{
Host = "localhost",
IntegratedSecurity = false,
UserName = string.Empty,
Password = string.Empty,
});
await Assert.ThrowsAsync<ProtocolEvidenceMissingException>(
() => client.GetTagMetadataAsync("anytag", CancellationToken.None));
}
[Fact]
public async Task GetTagMetadataAsync_ExplicitCredentials_AgainstLocalHistorian()
{
// Live verification of the explicit-creds tag-metadata path. Gated on
// HISTORIAN_USER + HISTORIAN_PASSWORD being set; skips cleanly otherwise. The path
// routes through WCF Windows transport security with Credentials.Windows.ClientCredential.
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)
|| string.IsNullOrWhiteSpace(user) || string.IsNullOrWhiteSpace(password)
|| !OperatingSystem.IsWindows())
{
return;
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = false,
UserName = user,
Password = password,
});
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
await client.GetTagMetadataAsync(testTag, CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal(testTag, metadata.Name);
}
[Fact] [Fact]
public async Task GetTagMetadataAsync_ReturnsConfiguredTestTagMetadata() public async Task GetTagMetadataAsync_ReturnsConfiguredTestTagMetadata()
{ {