Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
36cefd5
docs(fusion): spec for Phase 1 context injection
juemerson-at-purestorage Aug 4, 2026
3f2c1e7
fix(data): record GET /presets/workload as fleet-scoped
juemerson-at-purestorage Aug 5, 2026
92524ce
feat(context): add the PfbContext object with Kind/Form composition r…
juemerson-at-purestorage Aug 5, 2026
3b98012
feat(context): add DefaultContext/ContextOverride state and copy-on-w…
juemerson-at-purestorage Aug 5, 2026
880830b
Remove shadowed Kind/Form constants, add ValidateSet drift meta-test
juemerson-at-purestorage Aug 5, 2026
ad23a6c
fix(context): validate context composition before authentication and …
juemerson-at-purestorage Aug 5, 2026
f4b816d
feat(context): add Set-PfbContext and Clear-PfbContext (copy-on-write)
juemerson-at-purestorage Aug 5, 2026
ce5df0d
refactor(context): extract Resolve-PfbContextForm; tighten context tests
juemerson-at-purestorage Aug 5, 2026
bb97dd4
feat(context): add Invoke-PfbInContext with nesting and exception safety
juemerson-at-purestorage Aug 5, 2026
65f0006
fix(context): make the connection-cache restore actually restore, dro…
juemerson-at-purestorage Aug 5, 2026
f12acac
feat(context): resolve context precedence with a tri-state unset/empt…
juemerson-at-purestorage Aug 5, 2026
8d37a8d
test(context): pin the null-comparison and empty-array-wrapper contracts
juemerson-at-purestorage Aug 5, 2026
b86c4be
feat(context): inject context_names at the choke point before the ver…
juemerson-at-purestorage Aug 5, 2026
766180c
test(context): pin the empty-context guard at the capability gate
juemerson-at-purestorage Aug 5, 2026
d594def
feat(context): gate injection on recorded context_names support
juemerson-at-purestorage Aug 5, 2026
a7d87c6
test(context): constrain the capability gate's message in all four th…
juemerson-at-purestorage Aug 5, 2026
542eb55
feat(context): gate multi-value contexts on the shipped cardinality rule
juemerson-at-purestorage Aug 5, 2026
1640424
fix(context): scope the cardinality gate to endpoints declaring conte…
juemerson-at-purestorage Aug 6, 2026
38db2c2
feat(context): gate context kind against endpoint scope and require o…
juemerson-at-purestorage Aug 6, 2026
164a9c1
fix(context): let the version gate outrank the required-context gate
juemerson-at-purestorage Aug 6, 2026
a49df99
test(context): pin that a satisfied context is not reported as missing
juemerson-at-purestorage Aug 6, 2026
487ce58
fix(context): narrow the name-scoped context requirement to a measure…
juemerson-at-purestorage Aug 6, 2026
ff902d3
test(context): correct an inaccurate -Because on the unknown-scope pa…
juemerson-at-purestorage Aug 6, 2026
833c908
feat(context): pre-validate the admin authorization model, failing op…
juemerson-at-purestorage Aug 6, 2026
18e41fb
fix(context): read the admin model off the unwrapped response and pin…
juemerson-at-purestorage Aug 6, 2026
2571af7
refactor(context): resolve the authorization model only when a contex…
juemerson-at-purestorage Aug 6, 2026
299ddb1
fix(context): re-derive the preconditions the two new resolution site…
juemerson-at-purestorage Aug 6, 2026
dc87989
test(context): pin the rejected-connect logout, and record why the pr…
juemerson-at-purestorage Aug 6, 2026
26844ae
feat(context): annotate context-targeting failures with the active co…
juemerson-at-purestorage Aug 6, 2026
6a824ba
test(context): pin the hoisted error source and both scope-advice bra…
juemerson-at-purestorage Aug 6, 2026
09b7d59
fix(context): give the permission case a remedy that can actually work
juemerson-at-purestorage Aug 6, 2026
044d788
docs(test): record the measured $_ scoping, not the falsified leakage…
juemerson-at-purestorage Aug 6, 2026
a7cba8d
fix(context): gate Fusion contexts on admin locality, not authorizati…
juemerson-at-purestorage Aug 6, 2026
ca48c7f
fix(context): re-arm the disarmed remedy assertion, fix the vacuous 4…
juemerson-at-purestorage Aug 6, 2026
1f106da
test(context): guard per-item context attribution through the respons…
juemerson-at-purestorage Aug 6, 2026
c9dc30b
docs(context): narrow the items-pass-through comment to what the test…
juemerson-at-purestorage Aug 6, 2026
dc3fc29
feat(connection): populate Username from the login response on every …
juemerson-at-purestorage Aug 6, 2026
533fdd4
docs(connection): correct the StrictMode claim and strengthen two log…
juemerson-at-purestorage Aug 6, 2026
6e8b66b
fix(connection): key the API-token lookup on the array's admin name, …
juemerson-at-purestorage Aug 6, 2026
57af73c
docs(context): generate context-requirement NOTES for non-default-sco…
juemerson-at-purestorage Aug 6, 2026
837cf21
test(context): assert full accounting of non-default-scope endpoints
juemerson-at-purestorage Aug 6, 2026
ea495ba
fix(context): report unrenderable context scopes instead of dropping …
juemerson-at-purestorage Aug 6, 2026
b261dc6
docs(context): shorten the generated help delimiter to read well in G…
juemerson-at-purestorage Aug 6, 2026
e076928
test(context): pin the composition throw and drop -BeNullOrEmpty
juemerson-at-purestorage Aug 6, 2026
705229d
docs(context): correct three comments that overstate what the code does
juemerson-at-purestorage Aug 6, 2026
be093b7
fix(context): stop the generated help contradicting the runtime gate …
juemerson-at-purestorage Aug 6, 2026
f07bd61
test(context): assert every Public/ endpoint resolves to a capability…
juemerson-at-purestorage Aug 6, 2026
18bc0bc
test(context): pin contextScope provenance in the committed-map asser…
juemerson-at-purestorage Aug 6, 2026
1a95fa1
test(context): retire the /smtp allowlist now that issue #80 has landed
juemerson-at-purestorage Aug 13, 2026
5e5d1e7
chore(reports): regenerate report artifacts after rebase onto main
juemerson-at-purestorage Aug 13, 2026
dfaad90
fix(tools): adopt the target file's line ending when splicing the hel…
juemerson-at-purestorage Aug 13, 2026
f5d2ef3
docs(drift): correct both annotation notes and their dangling references
juemerson-at-purestorage Aug 13, 2026
1d7b297
test(drift): assert annotation wiring from source, not annotation wor…
juemerson-at-purestorage Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Data/PfbCapabilityMap.json
Original file line number Diff line number Diff line change
Expand Up @@ -11534,7 +11534,7 @@
},
"bodyProperties": {},
"contextScope": {
"scope": "array",
"scope": "fleet",
"provenance": "declared"
},
"parameterComponentOverrides": {
Expand Down
3 changes: 1 addition & 2 deletions Private/Assert-PfbApiCapability.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ function Assert-PfbApiCapability {
$map = Get-PfbCapabilityMap
if (-not $map) { return }

$normalizedEndpoint = '/' + $Endpoint.TrimStart('/')
$key = "$Method $normalizedEndpoint"
$key = Get-PfbEndpointKey -Method $Method -Endpoint $Endpoint
$entry = $map.endpoints.$key
if (-not $entry) { return }

Expand Down
627 changes: 627 additions & 0 deletions Private/Assert-PfbContextSupported.ps1

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions Private/Copy-PfbConnection.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copy-on-write for the connection object. Set-/Clear-PfbContext return a NEW connection and
# never mutate the caller's: context is a TARGETING mutation, unlike the transparent
# AuthToken/TokenExpiresAt writes the auto-reconnect path makes in place. See spec section 2.

function Copy-PfbConnection {
[CmdletBinding()]
[OutputType([PSCustomObject])]
param([Parameter(Mandatory)][PSCustomObject]$Array)

# .PSObject.Copy() is a shallow clone that PRESERVES PSTypeNames. Rebuilding from a
# [PSCustomObject]@{} literal would drop 'PureStorage.FlashBlade.Connection'.
# Deliberately does NOT touch $script:PfbArrays/$script:PfbDefaultArray -- repointing the
# caches is Update-PfbConnectionCache's single responsibility.
$Array.PSObject.Copy()
}

function Update-PfbConnectionCache {
[CmdletBinding()]
param([Parameter(Mandatory)][PSCustomObject]$Array)

# Both pointers must move or callers using the implicit default connection keep hitting
# the OLD object after the cmdlet "succeeded". Same idiom as the OAuth2 refresh path.
if ($script:PfbDefaultArray -and $script:PfbDefaultArray.Endpoint -eq $Array.Endpoint) {
$script:PfbDefaultArray = $Array
}
if ($script:PfbArrays -and $script:PfbArrays.ContainsKey($Array.Endpoint)) {
$script:PfbArrays[$Array.Endpoint] = $Array
}
}
137 changes: 134 additions & 3 deletions Private/Invoke-PfbApiRequest.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,106 @@ function Invoke-PfbApiRequest {
[string]$ApiVersionOverride
)

# Resolve and inject the Fusion context BEFORE Assert-PfbApiCapability, never after.
# Assert is the version gate this design leans on; if context_names lands in $QueryParams
# after Assert has run, Assert never sees the parameter and the check never fires. Do NOT
# move this to query-string construction below -- that is after Assert.
#
# Tri-state: $null means unset (inject nothing). A context that EXISTS but has no entries
# means "run this one call locally" -- also inject nothing, and specifically do not fall
# through to any lower-precedence context. Hence -ne $null plus an explicit count, never
# truthiness on the context object.
$resolvedContext = Resolve-PfbRequestContext -Array $Array -QueryParams $QueryParams

# ONE home for the tri-state predicate. Both branches need it and they are no longer
# adjacent (the required-context gate moved below Assert-PfbApiCapability -- see there), so a
# second copy would have to stay in exact negated agreement forever. $hasContext is a plain
# [bool], so "-not $hasContext" is legitimate: the truthiness ban is on the context OBJECT,
# which is a PSCustomObject and therefore unconditionally truthy.
$hasContext = ($null -ne $resolvedContext -and @($resolvedContext.Entries).Count -gt 0)

# Loaded once, above the branch, because BOTH branches gate on it. Get-PfbCapabilityMap
# memoizes, but hoisting it also guarantees the two branches rule on the same object.
$capabilityMap = Get-PfbCapabilityMap
if ($hasContext) {
# Gate before injecting: an endpoint with no recorded context_names support silently
# accepts the parameter on the wire, so the array will never tell the caller.
Assert-PfbContextCapability -Array $Array -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap

# Second gate: a multi-value context on an endpoint that accepts exactly one returns
# 400 code 15 with no hint about the fix, so translate it client-side.
Assert-PfbContextCardinality -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap

# Third gate: the context's KIND must be able to address the endpoint's scope. Runs AFTER
# the two above on purpose -- a wrong-kind context aimed at an endpoint that takes no
# context at all should hear about that first.
Assert-PfbContextKindMatchesScope -Method $Method -Endpoint $Endpoint -Context $resolvedContext -CapabilityMap $capabilityMap

# Clone first: $QueryParams is a reference to the CALLER's hashtable, and a targeting
# parameter must not leak back into a hashtable the caller may reuse for another call.
# Assigning the clone to the local also means the -AutoPaginate loop below rebuilds
# the query from the clone, so the context survives page 2+.
$QueryParams = if ($null -ne $QueryParams) { $QueryParams.Clone() } else { @{} }
$QueryParams[$script:PfbContextParameterName] =
@($resolvedContext.Entries | ForEach-Object { ConvertTo-PfbContextWireValue -Entry $_ }) -join ','
}

# Fail fast if the connected array's REST version doesn't support this endpoint/param/
# field, before any network call is made. Never sent if incompatible: see
# Assert-PfbApiCapability's header for why an unrecognized endpoint is a silent no-op.
Assert-PfbApiCapability -Array $Array -Method $Method -Endpoint $Endpoint -Body $Body -QueryParams $QueryParams -ApiVersion $ApiVersionOverride

# A symmetric pair, and BOTH halves sit below Assert-PfbApiCapability for the same measured
# reason. Neither injects anything and neither consults the endpoint, so neither has an
# ordering requirement against the version gate -- and a version failure is the more
# fundamental fact, so it must win. See the else branch for the 2.20 measurement that
# established this.
if ($hasContext) {
# Fourth shape gate: a LOCALLY authenticated admin cannot use a context at all, on any
# endpoint -- there is no local-array exemption, so every context is rejected once the
# admin is local.
#
# DEFENCE IN DEPTH -- this call is not expected to fire in production. Both sites that
# resolve AdminLocality run this same gate BEFORE they return a connection
# (Connect-PfbArray.ps1 resolves then gates; Set-PfbContext.ps1 resolves then gates, and
# discards its copy on the throw), so no connection object the module hands back to a
# caller can be resting on AdminLocality = 'local'. What this call covers is a future
# THIRD resolution site that forgets to gate, or a hand-constructed connection object.
# Deliberately NOT a per-call resolution site: probing locality here would mean one probe
# per request, so a thousand-iteration loop would mean a thousand probes.
#
# The gap this does NOT close: a session that only ever supplies a context through
# Invoke-PfbInContext never resolves locality at all, so a local admin there is caught
# REACTIVELY by the code-20 annotation in Add-PfbContextErrorAnnotation (see its header),
# not proactively here.
#
# Placement: it sits BELOW the injection and below Assert-PfbApiCapability. Above the
# injection it reintroduced exactly the failure Task 10 measured -- a local admin on a
# REST 2.20 array calling a context-capable endpoint that needs 2.23 was told to go
# obtain an LDAP admin, and only after doing so learned the real blocker was firmware.
# Assert-PfbContextCapability defers "recorded but array too old" to
# Assert-PfbApiCapability by design, so gates 1-3 all pass in that scenario and this one
# got the last word. Fails open on an indeterminate locality.
Assert-PfbContextAdminLocality -Array $Array -Context $resolvedContext
}
else {
# A fleet-scoped endpoint has no usable no-context default for a mutation or a
# name-scoped read. An explicit @() is still the caller saying "locally", so it reaches
# here too -- and for those calls that is exactly as broken as omitting the context.
# An unfiltered fleet-scoped read is exempt; the gate handles that distinction.
#
# Deliberately AFTER Assert-PfbApiCapability. Do NOT move this back above the version
# gate. Unlike the three shape gates, this one injects nothing, so it has no ordering
# requirement against Assert-PfbApiCapability -- and a version failure is the more
# fundamental fact, so it must win. Measured on an array at REST 2.20 calling
# Remove-PfbPresetWorkload -Name p1 with this call placed FIRST: the caller was told
# "requires a fleet context ... Set one with Set-PfbContext" instead of
# "DELETE /presets/workload requires REST 2.23 ... but the connected array is running
# REST 2.20". The first message cannot be acted on -- an array too old for the endpoint
# has no fleets to name.
Assert-PfbContextRequired -Method $Method -Endpoint $Endpoint -QueryParams $QueryParams -CapabilityMap $capabilityMap
}

# Certificate/OAuth2 sessions: proactively refresh the access token before it expires,
# rather than waiting for a 401. A proactive refresh generates no failed-authentication
# entry in the array's session log, unlike a reactive 401-triggered refresh.
Expand Down Expand Up @@ -148,6 +243,24 @@ function Invoke-PfbApiRequest {
$statusCode = [int]$_.Exception.Response.StatusCode
}

# One site, both throws. Built here rather than at each throw so the two paths cannot
# drift, and so $_ is unambiguously the OUTER catch's error record -- at the
# reconnect-failed throw below we sit after an inner try/catch, where which error $_
# names is a question nobody should have to answer.
#
# $resolvedContext and $capabilityMap are the function-scoped locals resolved once at
# the top of the request path. Do NOT re-resolve or re-fetch either here: a second
# resolution could disagree with the one that was actually sent on the wire, which
# would make the annotation name a context the failing call never used.
#
# Cost-only consequence, accepted deliberately: this also runs when the reconnect below
# goes on to SUCCEED, so a recovering request pays two side-effect-free helper calls it
# does not use. Both helpers must therefore stay non-throwing -- a throw in either would
# convert a request that was about to recover into a hard failure.
$apiError = ConvertTo-PfbApiError -Method $Method -Endpoint $Endpoint -ErrorRecord $_
$apiError = Add-PfbContextErrorAnnotation -Message $apiError -Context $resolvedContext `
-Method $Method -Endpoint $Endpoint -CapabilityMap $capabilityMap

# Auto-reconnect on an auth failure: ApiToken/Credential/PSCredential sessions
# have a cached long-lived API token to re-login with; Certificate sessions
# refresh the OAuth2 access token instead (fallback for what the proactive check
Expand Down Expand Up @@ -200,11 +313,11 @@ function Invoke-PfbApiRequest {
}

if (-not $reconnectSucceeded) {
throw (ConvertTo-PfbApiError -Method $Method -Endpoint $Endpoint -ErrorRecord $_)
throw $apiError
}
}
else {
throw (ConvertTo-PfbApiError -Method $Method -Endpoint $Endpoint -ErrorRecord $_)
throw $apiError
}
}

Expand All @@ -215,7 +328,17 @@ function Invoke-PfbApiRequest {
return $response
}

# Collect items
# Collect items. Items are added AS RECEIVED -- never project or rebuild them into a
# new PSCustomObject. A fanned-out (multi-array context) response carries a per-item
# `context` field naming the source array, and that is the caller's only way to tell
# which array an item came from. A "tidy up the response shape" refactor that rebuilt
# each item would silently destroy that attribution; the response layer deliberately
# reads only items / total_item_count / continuation_token off the body and leaves the
# items themselves alone. The per-item-context test in
# Tests/Invoke-PfbApiRequest.ContextInjection.Tests.ps1 fails if the per-item `context`
# field is dropped. That guard is deliberately narrower than the rule above: a rebuild
# that happened to forward `context` would still pass it. Treat the no-rebuild rule as
# the standard and the test as the backstop, not the definition.
if ($null -ne $response.items) {
foreach ($item in $response.items) {
$allItems.Add($item)
Expand Down Expand Up @@ -303,6 +426,14 @@ function Connect-PfbArrayInternal {
$authToken = $loginResponse.Headers['x-auth-token']
if ($authToken -is [array]) { $authToken = $authToken[0] }

# The response BODY is deliberately not parsed here, unlike the two /api/login sites in
# Connect-PfbArray, which take the admin's name from it (see Get-PfbLoginResponseUsername).
# This is a RECONNECT: the caller mutates the existing connection object in place, assigning
# only AuthToken and ConnectedAt, so Username and AdminLocality survive untouched and there is
# nothing here to refresh. The divergence is intentional -- do not read it as an oversight.
# Known marginal gap: an ApiToken session whose FIRST login body was malformed carries
# Username = $null forever, because a later well-formed reconnect body is never read. Fixing
# that means refreshing Username on the reconnect path, which is its own decision.
return [PSCustomObject]@{
AuthToken = $authToken
ConnectedAt = [datetime]::UtcNow
Expand Down
79 changes: 77 additions & 2 deletions Private/Invoke-PfbApiTokenLogin.ps1
Original file line number Diff line number Diff line change
@@ -1,3 +1,69 @@
function Get-PfbLoginResponseUsername {
<#
.SYNOPSIS
Reads the authenticated admin's name out of a POST /api/login 200 response body.
.DESCRIPTION
`POST /api/login` returns the authenticated admin's name in its 200 body, and has done in
EVERY REST version 2.0 through 2.28 -- charted across all 29 cached specs: the endpoint is
present in every version, its 200 response has a body in every version, and `username` is a
property of that body in every version. Only the schema ARRANGEMENT changed (inline, then a
named `Login` ref at 2.17, then `allOf: [Username]` at 2.26). What 2.26 added is acceptance
of a username/password REQUEST body -- neither the endpoint nor the response field. Do not
confuse the two, and do not add a version gate here: there is no version at which this
needs one.

The $null return is therefore MALFORMED-BODY TOLERANCE and nothing else. Do not re-justify
it on version grounds. Nothing in here is allowed to throw: a login that already
authenticated must never fail because a proxy rewrote the body or a test double omitted it.

Reads through PSObject.Properties rather than touching .Content / .username directly.
This is defensive BY CHOICE, not forced: this module does not set StrictMode anywhere, so
a direct read of an absent property would return $null rather than throwing. The reason to
keep it is that a real Invoke-WebRequest response always carries .Content while test
doubles and proxied/rewritten responses may not, and the property-bag read states that
expectation instead of relying on the absence of StrictMode to stay true.
(An earlier version of this comment claimed the module runs under StrictMode and that the
direct read would therefore be a terminating PropertyNotFound error. That was false --
`Set-StrictMode` appears nowhere in the module or the Pester harness. Do not reintroduce
the claim; if StrictMode is ever adopted, this read is already correct for it.)
.PARAMETER Response
The full response object from Invoke-WebRequest. $null and a Content-less object are both
acceptable inputs and both yield $null.
.OUTPUTS
[string] -- the array's own spelling of the admin name, or $null if the body did not carry
one. Never an empty string: unset and explicit-empty must not collapse.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter()]
[AllowNull()]
$Response
)

if ($null -eq $Response) { return $null }

$contentProperty = $Response.PSObject.Properties['Content']
if ($null -eq $contentProperty) { return $null }

# Invoke-WebRequest -UseBasicParsing gives Content as [string] on both editions; a byte[]
# body would simply fail the parse below and land on the $null return.
$content = [string]$contentProperty.Value
if ([string]::IsNullOrWhiteSpace($content)) { return $null }

try { $parsed = $content | ConvertFrom-Json -ErrorAction Stop }
catch { return $null }
if ($null -eq $parsed) { return $null }

# A JSON array (or a bare scalar) has no such property and correctly yields $null.
$nameProperty = $parsed.PSObject.Properties['username']
if ($null -eq $nameProperty) { return $null }

$name = [string]$nameProperty.Value
if ([string]::IsNullOrEmpty($name)) { return $null }
return $name
}

function Invoke-PfbApiTokenLogin {
<#
.SYNOPSIS
Expand All @@ -12,9 +78,15 @@ function Invoke-PfbApiTokenLogin {
The API token to exchange for a session token.
.PARAMETER SkipCertificateCheck
Bypass SSL certificate validation.
.OUTPUTS
[PSCustomObject] with AuthToken and Username. NOT a bare token string -- the 200 body
carries the array's own spelling of the admin name, which is what GET /admins?names= has
to match, and discarding it left the admin-locality gate inert for the default -ApiToken
parameter set. Username is $null when the body did not supply one; see
Get-PfbLoginResponseUsername.
#>
[CmdletBinding()]
[OutputType([string])]
[OutputType([PSCustomObject])]
param(
[Parameter(Mandatory)] [string]$Endpoint,
[Parameter(Mandatory)] [string]$ApiToken,
Expand All @@ -41,5 +113,8 @@ function Invoke-PfbApiTokenLogin {

$authToken = $loginResponse.Headers['x-auth-token']
if ($authToken -is [array]) { $authToken = $authToken[0] }
return $authToken
return [PSCustomObject]@{
AuthToken = $authToken
Username = Get-PfbLoginResponseUsername -Response $loginResponse
}
}
Loading
Loading