mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +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>
107 lines
4.4 KiB
C#
107 lines
4.4 KiB
C#
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using Microsoft.Azure.Functions.Worker;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Functions.Maintenance;
|
|
|
|
/// <summary>
|
|
/// Daily housekeeping for the <c>ScriptNameCache</c> table that backs Intune script
|
|
/// display-name resolution (Graph Add-On feature).
|
|
/// <list type="bullet">
|
|
/// <item>Positive (found) rows older than <see cref="PositiveTtl"/> are deleted so a
|
|
/// renamed Intune script eventually surfaces under its new name.</item>
|
|
/// <item>Negative (NotFound) rows expire much faster (<see cref="NotFoundTtl"/>) — they
|
|
/// exist only to suppress repeated 404 fetches for genuinely deleted scripts;
|
|
/// once the cache is gone the resolver will lazily re-check.</item>
|
|
/// <item>Per-tenant meta rows (<c>"{Kind}_$meta"</c>) live forever; they're tiny and
|
|
/// steer the resolver's "do a full pull vs. per-ID fallback" decision.</item>
|
|
/// </list>
|
|
/// Failure on a single row is logged and skipped — the timer never poisons. Next-day
|
|
/// run picks up whatever was missed.
|
|
/// </summary>
|
|
public class ScriptNameCacheCleanupFunction
|
|
{
|
|
/// <summary>Positive entries are usable for 7 days; covers all reasonable troubleshooting timeframes.</summary>
|
|
public static readonly TimeSpan PositiveTtl = TimeSpan.FromDays(7);
|
|
|
|
/// <summary>Negative-cache rows expire in 1h so deleted/renamed scripts come back quickly.</summary>
|
|
public static readonly TimeSpan NotFoundTtl = TimeSpan.FromHours(1);
|
|
|
|
/// <summary>03:00 UTC daily — outside European working hours, off the busy maintenance window.</summary>
|
|
private const string Cron = "0 0 3 * * *";
|
|
|
|
private readonly IScriptNameCacheRepository _repo;
|
|
private readonly ILogger<ScriptNameCacheCleanupFunction> _logger;
|
|
private readonly TimeProvider _time;
|
|
|
|
public ScriptNameCacheCleanupFunction(
|
|
IScriptNameCacheRepository repo,
|
|
ILogger<ScriptNameCacheCleanupFunction> logger)
|
|
: this(repo, logger, TimeProvider.System)
|
|
{
|
|
}
|
|
|
|
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic TTL math.</summary>
|
|
internal ScriptNameCacheCleanupFunction(
|
|
IScriptNameCacheRepository repo,
|
|
ILogger<ScriptNameCacheCleanupFunction> logger,
|
|
TimeProvider time)
|
|
{
|
|
_repo = repo;
|
|
_logger = logger;
|
|
_time = time;
|
|
}
|
|
|
|
[Function("ScriptNameCacheCleanup")]
|
|
public async Task Run([TimerTrigger(Cron)] object timer, CancellationToken cancellationToken)
|
|
{
|
|
await RunCoreAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>Testable core — bypasses TimerInfo / FunctionContext.</summary>
|
|
internal async Task<CleanupResult> RunCoreAsync(CancellationToken ct)
|
|
{
|
|
var now = _time.GetUtcNow();
|
|
var positiveCutoff = now - PositiveTtl;
|
|
var notFoundCutoff = now - NotFoundTtl;
|
|
var result = new CleanupResult();
|
|
|
|
await foreach (var entry in _repo.QueryExpiredAsync(positiveCutoff, notFoundCutoff, ct))
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
result.Scanned++;
|
|
|
|
try
|
|
{
|
|
await _repo.DeleteAsync(entry.TenantId, entry.Kind, entry.ScriptId, ct).ConfigureAwait(false);
|
|
if (entry.IsNotFound) result.NotFoundDeleted++;
|
|
else result.PositiveDeleted++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex,
|
|
"ScriptNameCacheCleanup: delete failed for tenant={TenantId} kind={Kind} id={ScriptId} — will retry next run",
|
|
entry.TenantId, entry.Kind, entry.ScriptId);
|
|
result.Failures++;
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation(
|
|
"ScriptNameCacheCleanup completed: scanned={Scanned} positiveDeleted={PositiveDeleted} notFoundDeleted={NotFoundDeleted} failures={Failures}",
|
|
result.Scanned, result.PositiveDeleted, result.NotFoundDeleted, result.Failures);
|
|
return result;
|
|
}
|
|
|
|
/// <summary>Summary counters returned from <see cref="RunCoreAsync"/> so tests can assert outcomes.</summary>
|
|
internal sealed class CleanupResult
|
|
{
|
|
public int Scanned { get; set; }
|
|
public int PositiveDeleted { get; set; }
|
|
public int NotFoundDeleted { get; set; }
|
|
public int Failures { get; set; }
|
|
}
|
|
}
|