TenantPulse

0.1.0

Read-only tenant health engine that evaluates Microsoft Intune and Entra configuration against a versioned set of checks, producing a deterministic, pseudonymized, scored snapshot.

Minimum PowerShell version

7.4

Installation Options

Copy and Paste the following command to install this package using PowerShellGet More Info

Install-Module -Name TenantPulse -RequiredVersion 0.1.0

Copy and Paste the following command to install this package using Microsoft.PowerShell.PSResourceGet More Info

Install-PSResource -Name TenantPulse -Version 0.1.0

You can deploy this package directly to Azure Automation. Note that deploying packages with dependencies will deploy all the dependencies to Azure Automation. Learn More

Manually download the .nupkg file to your system's default download location. Note that the file won't be unpacked, and won't include any dependencies. Learn More

Owners

Copyright

(c) 2026 Adam Gell. Licensed under MIT.

Package Details

Author(s)

  • Adam Gell

Tags

Intune Entra TenantHealth Graph

Functions

Export-PulseReport Get-PulseCheckCatalog Get-PulseTenantSnapshot Invoke-PulseAssessment Invoke-PulseCheck

Dependencies

Release Notes

## [0.1.0] - 2026-08-18

### Added

- Repository scaffold: Sampler build pipeline, module manifest, QA gates, and CI workflow.
- Snapshot store: `New-PulseSnapshotStore`, `Write-PulseDataset`, `Read-PulseDataset`,
 `Get-PulseSnapshotManifest`, `Set-PulseManifestEntry`, and the canonical serialization
 primitive `ConvertTo-PulseCanonicalJson`, with a hashed, reason-carrying manifest that
 every later collector/evaluator/renderer component builds on.
- Operator key + pseudonymization: `Get-PulseOperatorKey` auto-creates a 32-byte key
 (0600-equivalent permissions on non-Windows) at `~/.tenantpulse/operator.key` on first
 call and refuses to create or read one inside a snapshot root; `Get-PulsePseudonym`
 turns any value into a stable `tp-`-prefixed HMAC-SHA256 pseudonym under that key, so
 raw tenant IDs never need to appear in snapshots or reports.
- Check descriptor schema + loader: `Import-PulseCheckCatalog` reads and validates every
 `.psd1` check descriptor under a directory (default `source/Data/Checks`) with
 `Import-PowerShellDataFile`, returning an ordinally-sorted-by-`Id` array of
 `TenantPulse.CheckDescriptor` objects; an empty/missing catalog returns an empty array
 rather than throwing. `Test-PulseCheckDescriptor` validates one descriptor's schema
 with explicit per-field type enforcement (scalar `[string]` vs. required/allowed-empty
 `[string[]]`, reported as `must be a <type>, got <actual type>` rather than silently
 coercing), Id/Severity/Effort/Impact/Rule.Type patterns and enums, Rule.Function
 resolving via `Get-Command` at import time, required Consulting fields,
 References.Research/Authorities and, once Task 1.5 lands `DatasetMap.psd1`,
 cross-checks Data.Datasets membership against a map parsed exactly once per catalog
 load. All validation errors across a catalog are aggregated into a single thrown
 error, each line prefixed with its source filename and naming the offending
 descriptor and property, rather than failing on the first bad file. Schema documented
 verbatim in `source/Data/Checks/README.md`.
- Evaluator: `Invoke-PulseEvaluation` runs a check catalog against a snapshot store and
 produces the canonical findings document (`source/Private/Evaluate/FindingsSchema.md`),
 plus an in-memory-only `RedactionMap` covering every evidence identity seen (built at
 evaluate, applied at render). `New-PulseFinding` is the rule-result contract every
 Function rule (`Test-Pulse* -Datasets <hashtable>`) returns to produce `Pass`/`Warn`/
 `Fail` with evidence; Expression rules resolve straight to `[bool]` with no evidence.
 `NotApplicable` and `Error` are engine-assigned only: a missing/`Failed`/`Skipped`
 dataset degrades a check to `NotApplicable` quoting the manifest's own (already
 redacted) reason verbatim, and a throwing or malformed-output rule becomes `Error`
 without aborting the rest of the run. `Get-PulseGateStatus` is a Phase 1 gate stub -
 every gate resolves `Unknown`, which never degrades a check. Findings are sorted
 ordinally by check Id, each finding's evidence ordinally by sortKey then identity, and
 `generatedUtc` is pinned to the snapshot manifest's own `createdUtc` (never wall
 clock), so re-evaluating the same snapshot is byte-identical through
 `ConvertTo-PulseCanonicalJson`.
- **Settings expansion (Phase 2, `-ExpandSettings`)**: `Get-PulseTenantSnapshot
 -ExpandSettings` (and `Invoke-PulseAssessment -ExpandSettings`, a straight pass-through)
 decomposes every policy TenantPulse can currently setting-expand into individual
 canonical setting rows, on top of Phase 1's ordinary check-driven collection. Two typed
 expansion drivers cover two different policy shapes: `Invoke-PulseSettingsCatalogExpansion`
 walks the modern, definitionId-driven Settings Catalog (`configurationPolicies` +
 `ConfigurationPolicySetting.ListBeta` per policy); `Invoke-PulseTypedPolicyExpansion`
 covers compliance and legacy device configuration policies (`deviceCompliancePolicies`,
 `deviceConfigurations`), the older polymorphic `@odata.type`-typed Graph resources,
 decomposed via a hand-maintained property map (`source/Data/TypedPolicyMaps.psd1`) since
 no Graph-side settings catalog exists for them. `-ExpandSettings` is **default-off,
 deliberately**: the live gate against the Ivy24 lab tenant passed clean end to end, but
 actually flipping the default surfaced two real, wider-blast-radius costs (a
 `PSAvoidDefaultValueSwitchParameter` QA-gate lint failure, and at least two existing
 `Get-PulseTenantSnapshot` unit tests asserting on the manifest shape a default-on flip
 changes for every caller, not just opt-in ones) not appropriate to absorb under this same
 task's time budget - flipping the default is real, scoped follow-up work, not done here.
 Every expansion artifact is recorded in `manifest.expansions.<name>` and written to an
 immutable, content-addressed file, never a fixed name: three row-producer "families"
 (`settingsCatalog`, `compliance`, `deviceConfiguration`) each publish
 `expanded/<name>.<sha256>.jsonl` (one canonical JSON object per line, row-schema v1), and
 a single conflict-detection pass over every row from all three families publishes
 `expanded/conflicts.<sha256>.json` - one JSON document naming every `settingDefinitionId`
 that two or more policies disagree on, each with a four-state `assignmentOverlap` verdict
 (`proven`/`possible`/`none`/`unknown`; see `FindingsSchema.md`'s "Settings expansion
 artifacts" section for the exact schema). **The assignments-deferred split**: Settings
 Catalog rows carry `assignments: null` in this core slice because
 `ConfigurationPolicyAssignment.ListBeta` (the descriptor that would resolve which
 devices/users a Settings Catalog policy actually targets) is not yet a released GraphKit
 descriptor - any conflict involving such a row reports `assignmentOverlap: 'unknown'`
 with an explicit `'assignments-deferred: awaiting GraphKit release'` reason rather than a
 silently wrong verdict. Compliance and legacy device configuration policies do NOT have
 this gap - their assignment descriptors (`DeviceCompliancePolicyAssignment.List`/
 `DeviceConfigurationAssignment.List`) already shipped in GraphKit 0.1.1, so their rows
 carry real assignment data today. Closing the Settings Catalog side is tracked as its own
 follow-up slice ("Phase 2b"), not a silent limitation. A dedicated, serial perf container
 (`tests/Perf/ScaleAndMemory.Tests.ps1`, run via `./build.ps1 -Tasks build,perftest`, never
 part of the default test workflow) measures and budgets, at `[measured] x1.5` headroom, a
 5,000-policy synthetic Settings Catalog expansion + conflict-detection compute pass
 (mocked Graph), a 50,000-row `managedDevices` write+read memory ceiling, and raw
 per-policy dataset write scaling - see `docs/spike/2026-08-16-t27-perf-container.md` for
 the full recorded numbers, hardware, and method, and `docs/STATUS.md`'s own scale section
 for the documented gaps it surfaced. **Live-gated against the Ivy24 lab tenant end to
 end**: 781 Settings Catalog policies (`Partial`, 64 per-instance gaps, 0%
 unresolved-name rate, 131 redacted secret values), 40 compliance policies (`Partial`, 6
 gapped on an unmapped `@odata.type`), 15 device configuration policies (`Expanded`, zero
 gaps, 8 redacted secret values), and conflict detection surfacing **165 real conflict
 entries** across all three families (`assignmentOverlap` breakdown: `none`=8,
 `possible`=34, `unknown`=123, the 123 all involving at least one assignments-deferred
 Settings Catalog row) - not a zero-conflicts-by-luck outcome. `-FromSnapshot` re-derived
 all four expansion artifacts byte-identical to the original run.

### Changed

- Check descriptor loader hardening (post-review): closed PowerShell-coercion type holes
 that let array-typed `Id`/`Severity`/`Rule.Type`/etc. silently pass their
 pattern/enum checks and land wrong-typed in the loaded descriptor (also closing the
 matching duplicate-Id-detection bypass); prefixed every aggregated error line with its
 source filename so two files sharing a bad Id are distinguishable; hoisted
 `DatasetMap.psd1` parsing to once per catalog load with its own aggregated-error
 handling for a malformed map instead of a raw exception, and excluded a
 `DatasetMap.psd1` living inside the catalog directory from descriptor scanning;
 switched catalog ordering from culture-aware `Sort-Object` to an ordinal
 `[string]::CompareOrdinal` index-sort, matching `ConvertTo-PulseCanonicalJson`'s
 established pattern for deterministic ordering.
- **Final fix-wave, contract:** `Get-PulseTenantSnapshot`'s snapshot manifest `tenant`
 pseudonym is now derived from the resolved GraphKit tenant id (`$Context.TenantId`),
 never from `-ProfileId` - the same tenant under two differently-named profiles now
 pseudonymizes identically, and renaming a profile no longer changes the pseudonym.
 `Get-GraphContext` is now called before the snapshot store is created so the tenant id
 is available before anything is written. `Get-PulseTenantSnapshot -Path` and
 `Get-PulseCheckCatalog -Path` are renamed to `-OutputPath`/`-CatalogPath` respectively
 (`-Path` kept as a deprecated alias for one release). `Export-PulseReport`'s return
 object now carries `FindingsPath`, matching `Invoke-PulseAssessment`'s return shape.
- **Final fix-wave, snapshot-store boundary:** `New-PulseSnapshotStore` now clears
 `datasets/`, `reference/` and `expanded/` before reuse (unconditionally, not behind a
 `-Force` flag), so a prior run's or a foreign file left in an existing store path can
 no longer silently survive into a new run. `Get-PulseSnapshotStore` now type-validates
 `datasets` (non-null object), `createdUtc` (a parseable timestamp) and `producer`
 (non-null object) when opening a store, throwing a specific path+field error instead of
 producing a confident-but-wrong evaluation or a mid-run crash. `Write-Pulse

FileList

Version History

Version Downloads Last updated
0.1.3 9 8/20/2026
0.1.2 6 8/20/2026
0.1.1 5 8/19/2026
0.1.0 (current version) 4 8/19/2026