mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +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>
53 lines
2.1 KiB
C#
53 lines
2.1 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Functions.Services.GraphResolution;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests.GraphResolution;
|
|
|
|
/// <summary>In-memory <see cref="IGraphFeatureDetector"/> fake for resolver tests.</summary>
|
|
internal sealed class FakeGraphFeatureDetector : IGraphFeatureDetector
|
|
{
|
|
public Dictionary<string, GraphTenantTokenContext?> ContextByTenant { get; } = new();
|
|
public List<string> InvalidateCalls { get; } = new();
|
|
|
|
public Task<bool> HasPermissionAsync(string tenantId, string graphAppRole, CancellationToken ct = default)
|
|
{
|
|
ContextByTenant.TryGetValue(tenantId, out var ctx);
|
|
return Task.FromResult(ctx != null && ctx.GrantedRoles.Contains(graphAppRole));
|
|
}
|
|
|
|
public Task<IReadOnlySet<string>> GetGrantedRolesAsync(string tenantId, CancellationToken ct = default)
|
|
{
|
|
ContextByTenant.TryGetValue(tenantId, out var ctx);
|
|
return Task.FromResult<IReadOnlySet<string>>(
|
|
ctx?.GrantedRoles ?? (IReadOnlySet<string>)new HashSet<string>());
|
|
}
|
|
|
|
public Task<GraphTenantTokenContext?> TryGetTokenContextAsync(string tenantId, CancellationToken ct = default)
|
|
{
|
|
ContextByTenant.TryGetValue(tenantId, out var ctx);
|
|
return Task.FromResult(ctx);
|
|
}
|
|
|
|
/// <summary>When set for a tenant, <see cref="GetSnapshotAsync"/> reports it as transient.</summary>
|
|
public HashSet<string> TransientTenants { get; } = new();
|
|
|
|
public Task<GraphPermissionSnapshot> GetSnapshotAsync(string tenantId, CancellationToken ct = default)
|
|
{
|
|
if (TransientTenants.Contains(tenantId))
|
|
{
|
|
return Task.FromResult(new GraphPermissionSnapshot { IsTransient = true });
|
|
}
|
|
ContextByTenant.TryGetValue(tenantId, out var ctx);
|
|
return Task.FromResult(new GraphPermissionSnapshot
|
|
{
|
|
GrantedRoles = ctx?.GrantedRoles ?? (IReadOnlySet<string>)new HashSet<string>(),
|
|
IsTransient = false,
|
|
});
|
|
}
|
|
|
|
public void InvalidateTenant(string tenantId) => InvalidateCalls.Add(tenantId);
|
|
}
|