mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
e4c4dd57db
Adds opt-in resolution of Intune Platform Script + Remediation display names in
session timelines via a tenant-side appRoleAssignment grant. No app manifest
change, no impact on customers who don't enable it. Architecture is reusable for
future optional Graph permissions: catalog -> detector -> resolver -> endpoint.
Backend
- GraphFeatureDetector parses the `roles` claim from the SP access token and
caches the (token, roles) bundle per tenant. GetSnapshotAsync surfaces a
transient flag so admin-UI callers can render "unknown" instead of a
misleading "0 of 0 granted" when token acquire times out.
- GraphTokenService now accepts a CancellationToken so optional-feature callers
can apply tight budgets instead of inheriting the 5+15+30 s retry chain.
- ScriptDisplayNameResolver: cache-first, list-pull-on-cold-tenant, per-ID
fallback. Throttle-aware abort: skip per-ID for kinds whose full-pull hit a
transient failure; break the per-ID loop on the first HttpRequestException.
Total budget 5 s, with a tight 1.5 s sub-budget on the token acquire.
- Azure Table `ScriptNameCache` + direct-TableClient fail-loud repository
(mirrors TableOffboardingAuditRepository pattern). Daily
ScriptNameCacheCleanupFunction: 7 d positive / 1 h NotFound TTL.
- POST /api/tenants/{id}/scripts/display-names (body-carried refs to dodge URL
length limits; backend cap 200 refs/request).
- GET /api/tenants/{id}/graph-permissions/status (4 s budget; includes app
ClientId so the UI can render an exact grant command).
- POST /api/tenants/{id}/graph-permissions/refresh (invalidates token cache).
- All three routes registered in EndpointAccessPolicyCatalog (MemberRead for the
resolve, TenantAdminOrGA for the admin-UI surfaces).
Web
- `useScriptDisplayNames` hook with stable refset fingerprint (no spurious
refetches on parent re-renders); clears stale map on tenant/refset change so
a session switch never leaks names across tenants.
- Chunked POST loop: 200-ref batches with partial-success semantics. Preserves
earlier chunks when later ones fail (non-2xx OR transport throw); only
TokenExpiredError propagates so auth flow can react.
- ScriptExecutions renders " . {DisplayName}" alongside existing label/short-id,
keyed by Kind + policyId so Platform/Remediation under the same id never
collide.
- New Settings section "Optional Graph capabilities": status table with granted
/ not granted / unknown, copy-paste PS one-liner with ClientId baked in,
refresh button gated on response.ok.
Customer-facing
- scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1: idempotent
grant / verify / revoke script. -Features ScriptDisplayNames (high level) or
-Permissions ... (admin-UI generated). Pure ASCII for PS 5.1 compat.
- docs/customer-graph-add-on-permissions.md: end-to-end customer guide
covering prerequisites, the appRoleAssignment pattern, and revocation.
Tests
- 1718 backend xUnit / 364 web vitest. Highlights: JWT roles parsing edge
cases; full-pull-429 suppresses per-ID; per-ID-429 breaks loop; transient
detection via GetSnapshotAsync; chunked partial success (HTTP failure + thrown
transport); TokenExpiredError propagation; EndpointPolicyCatalogCompleteness
covers the three new routes by reflection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
8.3 KiB
C#
210 lines
8.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IdentityModel.Tokens.Jwt;
|
|
using System.Security.Claims;
|
|
using AutopilotMonitor.Functions.Services.GraphResolution;
|
|
using AutopilotMonitor.Shared.Models.Graph;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Xunit;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
|
|
|
/// <summary>
|
|
/// Pure-logic tests for <see cref="GraphFeatureDetector.TryParseToken"/>. The cache +
|
|
/// token-service orchestration is covered indirectly by <c>ScriptDisplayNameResolverTests</c>
|
|
/// (PR-C), where a fake context is set up end-to-end.
|
|
/// </summary>
|
|
public class GraphFeatureDetectorTests
|
|
{
|
|
[Fact]
|
|
public void TryParseToken_returns_single_role()
|
|
{
|
|
var token = BuildJwt(
|
|
new[] { new Claim("roles", GraphAppPermissions.DeviceManagementScriptsReadAll) },
|
|
expires: DateTime.UtcNow.AddHours(1));
|
|
|
|
Assert.True(GraphFeatureDetector.TryParseToken(token, out var roles, out var expiresAt));
|
|
Assert.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll, roles);
|
|
Assert.Single(roles);
|
|
Assert.True(expiresAt > DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParseToken_collects_multiple_roles_case_insensitive()
|
|
{
|
|
var token = BuildJwt(
|
|
new[]
|
|
{
|
|
new Claim("roles", "DeviceManagementScripts.Read.All"),
|
|
new Claim("roles", "DeviceManagementConfiguration.Read.All"),
|
|
new Claim("roles", "DeviceManagementManagedDevices.Read.All"),
|
|
},
|
|
expires: DateTime.UtcNow.AddHours(1));
|
|
|
|
Assert.True(GraphFeatureDetector.TryParseToken(token, out var roles, out _));
|
|
Assert.Equal(3, roles.Count);
|
|
// Case-insensitive contains: tenant might issue with slightly different casing.
|
|
Assert.Contains("devicemanagementscripts.read.all", roles);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParseToken_returns_empty_set_when_roles_claim_missing()
|
|
{
|
|
var token = BuildJwt(
|
|
new[] { new Claim("scp", "User.Read") },
|
|
expires: DateTime.UtcNow.AddHours(1));
|
|
|
|
Assert.True(GraphFeatureDetector.TryParseToken(token, out var roles, out _));
|
|
Assert.Empty(roles);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParseToken_filters_empty_role_values()
|
|
{
|
|
var token = BuildJwt(
|
|
new[]
|
|
{
|
|
new Claim("roles", ""),
|
|
new Claim("roles", " "),
|
|
new Claim("roles", "DeviceManagementScripts.Read.All"),
|
|
},
|
|
expires: DateTime.UtcNow.AddHours(1));
|
|
|
|
Assert.True(GraphFeatureDetector.TryParseToken(token, out var roles, out _));
|
|
Assert.Single(roles);
|
|
Assert.Contains("DeviceManagementScripts.Read.All", roles);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParseToken_fails_on_garbage_input()
|
|
{
|
|
Assert.False(GraphFeatureDetector.TryParseToken("not.a.jwt", out _, out _));
|
|
Assert.False(GraphFeatureDetector.TryParseToken("", out _, out _));
|
|
Assert.False(GraphFeatureDetector.TryParseToken("definitely-not-base64-and-no-dots", out _, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void TryParseToken_reads_expiry_correctly()
|
|
{
|
|
var fixedExpiry = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc);
|
|
var token = BuildJwt(new[] { new Claim("roles", "x") }, expires: fixedExpiry);
|
|
|
|
Assert.True(GraphFeatureDetector.TryParseToken(token, out _, out var expiresAt));
|
|
// JWT 'exp' is unix seconds — second precision is sufficient.
|
|
Assert.Equal(fixedExpiry, expiresAt.UtcDateTime);
|
|
}
|
|
|
|
// ── HasPermission helper observable behaviour via cache-only path ────────
|
|
|
|
[Fact]
|
|
public void IsFeatureGranted_via_catalog()
|
|
{
|
|
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
GraphAppPermissions.DeviceManagementScriptsReadAll,
|
|
};
|
|
|
|
Assert.True(GraphFeatureCatalog.IsFeatureGranted(
|
|
GraphFeatureCatalog.FeatureScriptDisplayNames, roles));
|
|
Assert.False(GraphFeatureCatalog.IsFeatureGranted("UnknownFeature", roles));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsFeatureGranted_returns_false_when_required_permission_missing()
|
|
{
|
|
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "Foo.Bar" };
|
|
|
|
Assert.False(GraphFeatureCatalog.IsFeatureGranted(
|
|
GraphFeatureCatalog.FeatureScriptDisplayNames, roles));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsFeatureGranted_accepts_any_collection_shape_without_unsafe_cast()
|
|
{
|
|
// Verifies Finding 3: the catalog API no longer demands ISet<string>, so an
|
|
// IReadOnlySet (or any IEnumerable) works without casting.
|
|
IReadOnlySet<string> roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
GraphAppPermissions.DeviceManagementScriptsReadAll,
|
|
};
|
|
Assert.True(GraphFeatureCatalog.IsFeatureGranted(
|
|
GraphFeatureCatalog.FeatureScriptDisplayNames, roles));
|
|
|
|
// Even a plain array (case-insensitive match handled internally).
|
|
string[] rolesArray = { "devicemanagementscripts.read.all" };
|
|
Assert.True(GraphFeatureCatalog.IsFeatureGranted(
|
|
GraphFeatureCatalog.FeatureScriptDisplayNames, rolesArray));
|
|
|
|
// Null collection is safely false.
|
|
Assert.False(GraphFeatureCatalog.IsFeatureGranted(
|
|
GraphFeatureCatalog.FeatureScriptDisplayNames, (IEnumerable<string>?)null));
|
|
}
|
|
|
|
// ── ScriptRef parsing (Shared/Models/Graph) ─────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("Platform:abc-123", ScriptKind.Platform, "abc-123")]
|
|
[InlineData("platform:abc-123", ScriptKind.Platform, "abc-123")]
|
|
[InlineData("Remediation:def", ScriptKind.Remediation, "def")]
|
|
[InlineData("REMEDIATION:def", ScriptKind.Remediation, "def")]
|
|
public void ScriptRef_parses_canonical_form(string input, ScriptKind kind, string id)
|
|
{
|
|
Assert.True(ScriptRef.TryParse(input, out var r));
|
|
Assert.Equal(kind, r.Kind);
|
|
Assert.Equal(id, r.Id);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(null)]
|
|
[InlineData("nokind")]
|
|
[InlineData("UnknownKind:abc")]
|
|
[InlineData(":id")]
|
|
[InlineData("Platform:")]
|
|
public void ScriptRef_rejects_malformed(string? input)
|
|
{
|
|
Assert.False(ScriptRef.TryParse(input, out _));
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
private static string BuildJwt(IEnumerable<Claim> claims, DateTime expires)
|
|
{
|
|
// JWT without signature is fine — the detector NEVER validates signature
|
|
// (this is our own token, read seconds after acquisition from Azure AD over TLS).
|
|
var jwt = new JwtSecurityToken(
|
|
issuer: "https://sts.windows.net/test",
|
|
audience: "https://graph.microsoft.com",
|
|
claims: claims,
|
|
notBefore: null,
|
|
expires: expires);
|
|
return new JwtSecurityTokenHandler().WriteToken(jwt);
|
|
}
|
|
|
|
/// <summary>Smoke: cache hit short-circuits — no token-service call needed.</summary>
|
|
[Fact]
|
|
public void Cache_hit_returns_cached_context_without_acquire()
|
|
{
|
|
var cache = new MemoryCache(new MemoryCacheOptions());
|
|
const string tenantId = "11111111-1111-1111-1111-111111111111";
|
|
var preSeeded = new GraphTenantTokenContext
|
|
{
|
|
TenantId = tenantId,
|
|
AccessToken = "fake",
|
|
GrantedRoles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
GraphAppPermissions.DeviceManagementScriptsReadAll,
|
|
},
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
|
};
|
|
cache.Set($"graph-feature-detector:{tenantId}", preSeeded);
|
|
|
|
// We can verify cache layout works by reading back through the same key.
|
|
// Full Detector orchestration is covered in PR-C via the Resolver integration tests.
|
|
Assert.True(cache.TryGetValue($"graph-feature-detector:{tenantId}",
|
|
out GraphTenantTokenContext? roundtrip));
|
|
Assert.Equal(tenantId, roundtrip!.TenantId);
|
|
Assert.Contains(GraphAppPermissions.DeviceManagementScriptsReadAll, roundtrip.GrantedRoles);
|
|
}
|
|
}
|