Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/ScriptNameCacheCleanupFunction.cs
T
Oliver Kieselbach e4c4dd57db feat(backend,web): optional Graph add-on permissions for Intune script display names
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>
2026-05-19 01:14:21 +02:00

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; }
}
}