Compare commits

...

3 Commits

Author SHA1 Message Date
Oliver Kieselbach d3261fb890 feat(ops): ops events for Collect Logs usage and diagnostics-upload enablement
CollectLogsQuickConfigEnabled fires when a tenant admin switches on Hosted
diagnostics upload through the session Collect Logs quick-config dialog
(PUT ?intent=collect-logs -> write source portal-collect-logs); every other
enable/disable path emits DiagnosticsUploadEnabled/Disabled (PUT, PATCH,
revert, MCP). SessionActionQueued records portal-queued server actions.
DiagnosticsUploadConfigChange is the single definition behind the
diagnosticsUploadConfigured feature flag and the flip detection. Event types
dual-registered in the OpsAlertRules catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 19:30:20 +02:00
Oliver Kieselbach b3bbcf1300 fix(agent): rotate_config applies the fetched config; diagnostics skip gates report their reason
rotate_config only stored the fetched config in RemoteConfigService and never
merged it into the running AgentConfiguration, so the portal quick-config flow
(enable diagnostics upload, then collect) ran the diagnostics gate against the
stale mode=Off and failed with a generic diagnostics_upload_failed 4 ms later.
The action now runs the same ApplyFetchedConfig path as the startup fetch and
reports config_fetch_failed when the live fetch fell back to cache/defaults.
Skipped uploads return a DiagnosticsUploadResult.Skipped with the gate name
(diagnostics_skipped_mode_off / _not_configured / _onfailure_success) instead
of null, so server_action_failed names the actual reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 19:30:00 +02:00
Oliver Kieselbach a306cd9e40 feat(security): resolve admin identity bindings automatically at grant time; per-tenant bindings panel
Granting a cross-tenant role is "type the UPN" again. AdminIdentityResolver derives the home
tenant + object id from sign-in history (UserActivity: both values came from validated tokens,
most recent sign-in wins), falls back to the UPN domain of an onboarded tenant (tid only, oid
pinned on first sign-in), and never guesses when a UPN was seen under two tenants. Only a
never-seen domain yields 422 HomeTenantUnresolved, on which the portal shows a home-tenant picker.

- IMetricsRepository.GetSignInIdentitiesByUpnAsync (filtered UserActivity scan, grant-time only)
- Grant endpoints: homeTenantId/objectId optional overrides; bindings dropped from the list bodies
- Web: identity fields and binding pills removed from the delegated / tenant-group forms;
  new collapsed "Identity bindings" panel in Tenant Management -> Edit Tenant Configuration
  (bindings homed in that tenant; inline edit / remove via global/identity-bindings)
- Tests: AdminIdentityResolverTests; grant validation now accepts an absent home tenant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 19:21:47 +02:00
37 changed files with 1184 additions and 199 deletions
+25 -11
View File
@@ -83,19 +83,30 @@ winner). Two accounts racing the first sign-in inside the home tenant therefore
Row lookups stay cached by UPN; the binding has its own 30 s cache keyed by UPN and is invalidated on
pin, rebind and removal. A rebind on one scaled-out instance converges on the others within seconds.
## Grants bind first
## Grants bind first — and resolve the identity themselves
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` take
`homeTenantId` (GUID, required) and `objectId` (GUID, optional) and call `EnsureBoundAsync` **before**
writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
Granting is "type the UPN". The three grant endpoints (`POST auth/global-admins`,
`POST global/delegated-admins`, `POST global/tenant-groups/{id}/assignees`) accept `homeTenantId` /
`objectId` only as optional overrides; normally `AdminIdentityResolver` supplies them:
1. **Sign-in history** (`UserActivity`, filtered on the UPN): the person has signed in before ⇒ tid + oid
are known, both from validated tokens. The most recent sign-in wins for the oid. A UPN seen under
**more than one tenant** is never auto-resolved — that is the attack shape the binding exists for.
2. **UPN domain → onboarded tenant** (`TenantConfiguration.DomainName`, exactly one match): tid only; the
oid is pinned on the first sign-in.
3. Neither ⇒ **422 `HomeTenantUnresolved`**; the portal then shows a home-tenant picker (onboarded
tenants) and retries with `homeTenantId`. This is the only manual step, and only for a UPN whose
domain the platform has never seen.
`AddGlobalAdminAsync`, `DelegatedAdminService.UpsertAsync` and `AssignGroupAsync` then take the resolved
`homeTenantId` / `objectId` and call `EnsureBoundAsync` **before** writing the role row. `EnsureBoundAsync` creates the binding, keeps a compatible one (same tenant; the
supplied object id equals the pin or upgrades an unpinned one), and throws
`IdentityBindingConflictException` for a different tenant or a different pinned object id — the
functions map that to **409**. A grant can therefore never silently re-home a UPN.
Why the object id may be omitted: the operator granting an external MSP admin has no directory access
to the partner tenant, so the person's `oid` is frequently unknown at grant time; the home tenant is
always known (it is who the grant is for). The first sign-in from that tenant pins it; from then on a
recycled UPN in the same tenant is refused.
Why the object id may be unknown: a person who has never signed in has no history to resolve from, and
the operator granting an external MSP admin has no directory access to the partner tenant. The first
sign-in from the bound tenant pins it; from then on a recycled UPN in the same tenant is refused.
## Explicit rebind surface
@@ -106,9 +117,10 @@ recycled UPN in the same tenant is refused.
| `DELETE global/identity-bindings/{upn}` | GlobalAdminOnly | Make every role row of the UPN inert without touching the rows. Self-removal is refused. |
Both mutations are audited under the binding's home tenant (`AdminIdentityBinding` entity, previous
values in the details) and logged at Warning. The list endpoints of delegated admins and tenant groups
return `bindings` alongside their rows so the management UI can show a per-UPN context pill (home tenant,
pin state) and require the home tenant in its grant / assign forms.
values in the details) and logged at Warning. The portal surfaces this in **Tenant Management → Edit
Tenant Configuration → Identity bindings** (collapsed): the bindings homed in that tenant, with inline
edit (re-home / re-pin; empty object id ⇒ re-pinned on the next sign-in) and remove. The grant forms
themselves carry no identity fields — the binding is a background property of the grant.
## Lifecycle and secondary effects
@@ -144,6 +156,8 @@ Same row, binding without an object id: the first `tid=1111…` sign-in pins its
* `src/Backend/AutopilotMonitor.Functions/Security/AdminIdentity.cs` — the identity record and its factories.
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityBindingService.cs` — verdict, pin, grant-time binding, rebind; the table entity.
* `src/Backend/AutopilotMonitor.Functions/Services/AdminIdentityResolver.cs` — sign-in-history / UPN-domain resolution at grant time (`AdminIdentityResolverTests`).
* `src/Web/autopilot-monitor-web/app/admin/components/IdentityBindingsSection.tsx` — the per-tenant inspect/correct panel.
* `src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs`, `DelegatedAdminService.cs` — resolution order.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs` — `TryPinIdentityObjectIdAsync` and the binding CRUD.
* `src/Backend/AutopilotMonitor.Functions/Functions/Admin/IdentityBindingManagementFunction.cs` — the rebind surface and the shared `IdentityBindingRequest.Validate`.
+4
View File
@@ -1,5 +1,9 @@
# Log
## 2026-08-29 (6)
* **Update**: `backend/admin-identity-binding.md` — grants are self-maintaining: `AdminIdentityResolver` derives the home tenant + object id from sign-in history (`UserActivity`), falls back to the UPN domain of an onboarded tenant, refuses to guess when a UPN was seen under two tenants; 422 `HomeTenantUnresolved` + portal tenant picker only for a never-seen domain. Identity fields and pills removed from the delegated / tenant-group forms; inspect/correct lives in Tenant Management → Edit Tenant Configuration → Identity bindings (collapsed).
## 2026-08-29 (5)
* **Creation**: Added `backend/admin-identity-binding.md` — cross-tenant roles (GlobalAdmin / GlobalReader, delegated MSP) were resolved from the UPN string alone while the API accepts tokens from every Entra tenant. New `AdminIdentityBindings` table (RK = UPN → home `TenantId` + `ObjectId`), `AdminIdentity` (upn + tid + oid) as the key both role services resolve on, binding checked after the row and before the Pro gate, object id pinned on the first sign-in from the bound tenant (ETag-guarded), grants require `homeTenantId` and refuse to re-home (409), `global/identity-bindings` GET/PUT/DELETE as the audited rebind path, `RequestContext.ObjectId`, throttle bucket keyed on oid. Legacy rows are inert until bound — seed bindings before the backend deploy.
@@ -527,7 +527,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
// On-demand mid-session (no outcome yet): the OnFailure gate must NOT skip —
// there is no known success to skip on. Reaching the archive build (sentinel
// throw → error result) proves the gate was passed; a skip would return null.
// throw → error result) proves the gate was passed; a skip would return Skipped.
using var rig = new Rig();
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: null, fileNameSuffix: "server-requested");
@@ -543,7 +543,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
var result = await BuildGateProbe(rig.Logger, "OnFailure")
.CreateAndUploadAsync(enrollmentSucceeded: true);
Assert.Null(result);
Assert.NotNull(result);
Assert.True(result!.Skipped);
Assert.False(result.Success);
Assert.Equal(DiagnosticsUploadResult.SkipOnFailureSucceeded, result.ErrorCode);
}
[Fact]
@@ -553,7 +556,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
var result = await BuildGateProbe(rig.Logger, "Off")
.CreateAndUploadAsync(enrollmentSucceeded: null);
Assert.Null(result);
// The gate name travels in ErrorCode so the dispatcher's server_action_failed
// event says "mode_off" instead of a generic "diagnostics_upload_failed".
Assert.NotNull(result);
Assert.True(result!.Skipped);
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, result.ErrorCode);
}
[Theory]
@@ -122,6 +122,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Assert.Equal("config_fetch_failed", (string)typed["failureReason"]);
}
[Fact]
public async Task Dispatch_request_diagnostics_skipped_by_gate_surfaces_gate_name_as_failure_reason()
{
// Session 2550bcea (2026-08-29): the on-demand collection was refused by the
// mode=Off gate and the portal only saw a generic "diagnostics_upload_failed".
// The gate name must travel through to the event so the operator can tell a
// config gate from a real upload error.
using var rig = new Rig();
rig.DiagnosticsResult = DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
await rig.Build().DispatchAsync(new List<ServerAction>
{
new ServerAction { Type = ServerActionTypes.RequestDiagnostics, QueuedAt = DateTime.UtcNow },
});
Assert.Equal(1, rig.DiagnosticsCalls);
Assert.Empty(rig.EventsOfType("server_action_executed"));
var failed = rig.EventsOfType("server_action_failed").Single();
var typed = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(failed.TypedPayload!);
Assert.Equal(DiagnosticsUploadResult.SkipModeOff, (string)typed["failureReason"]);
}
[Fact]
public async Task Dispatch_unknown_action_type_emits_failed_with_unknown_action_type_reason()
{
@@ -45,10 +45,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
public string SasUrlPrefix { get; set; }
/// <summary>Human-readable error code/message when upload failed. Null on success or skip.</summary>
/// <summary>Human-readable error code/message when upload failed or was skipped. Null on success.</summary>
public string ErrorCode { get; set; }
/// <summary>
/// True when no package was built because a configuration gate said so (mode=Off,
/// destination not configured, OnFailure + known success). <see cref="ErrorCode"/>
/// then carries the machine-readable gate name so the emitted
/// <c>server_action_failed</c> / <c>diagnostics_upload_failed</c> event names the
/// actual reason instead of a generic "upload failed".
/// </summary>
public bool Skipped { get; set; }
public bool Success => BlobName != null;
public const string SkipModeOff = "diagnostics_skipped_mode_off";
public const string SkipNotConfigured = "diagnostics_skipped_not_configured";
public const string SkipOnFailureSucceeded = "diagnostics_skipped_onfailure_success";
public static DiagnosticsUploadResult SkippedBy(string gate) =>
new DiagnosticsUploadResult { Skipped = true, ErrorCode = gate };
}
/// <summary>
@@ -191,7 +207,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// <summary>
/// Creates a diagnostics ZIP and uploads it to the configured Blob Storage container.
/// Returns a DiagnosticsUploadResult with BlobName set on success, or ErrorCode set on failure.
/// Returns null if the upload was skipped (mode=Off, not configured, or OnFailure+succeeded).
/// A configuration gate (mode=Off, not configured, OnFailure+succeeded) returns a result
/// with <see cref="DiagnosticsUploadResult.Skipped"/> and the gate name in ErrorCode.
/// This method is non-fatal: all exceptions are caught and logged.
/// </summary>
/// <param name="enrollmentSucceeded">
@@ -214,14 +231,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
var mode = _configuration.DiagnosticsUploadMode ?? "Off";
if (string.Equals(mode, "Off", StringComparison.OrdinalIgnoreCase))
{
_logger.Debug("Diagnostics upload disabled (mode=Off)");
return null;
_logger.Info("Diagnostics upload skipped: DiagnosticsUploadMode=Off on the running agent.");
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipModeOff);
}
if (!_configuration.DiagnosticsUploadEnabled)
{
_logger.Debug("Diagnostics upload skipped: not configured for this tenant");
return null;
_logger.Info($"Diagnostics upload skipped: no upload destination configured for this tenant (mode={mode}).");
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipNotConfigured);
}
// Null outcome (on-demand mid-session) passes through: the OnFailure gate only
@@ -229,7 +246,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (string.Equals(mode, "OnFailure", StringComparison.OrdinalIgnoreCase) && enrollmentSucceeded == true)
{
_logger.Info("Diagnostics upload skipped: enrollment succeeded and mode=OnFailure");
return null;
return DiagnosticsUploadResult.SkippedBy(DiagnosticsUploadResult.SkipOnFailureSucceeded);
}
_logger.Info($"Creating diagnostics package (mode={mode}, enrollmentSucceeded={(enrollmentSucceeded.HasValue ? enrollmentSucceeded.ToString() : "n/a")}{(fileNameSuffix != null ? $", suffix={fileNameSuffix}" : "")})...");
@@ -115,10 +115,11 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// <summary>
/// Applies a live-fetched config to the runtime: merge, tracker ceilings, log level,
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path
/// and the post-registration recovery path so both apply identical semantics.
/// integrity SHA + check, bootstrap-config cleanup. Shared by the Phase 4 startup path,
/// the post-registration recovery path and the live <c>rotate_config</c> ServerAction
/// (<see cref="ServerControlPlane.BuildDispatcher"/>) so all three apply identical semantics.
/// </summary>
private static RemoteConfigMergeResult ApplyFetchedConfig(
internal static RemoteConfigMergeResult ApplyFetchedConfig(
AgentConfiguration agentConfig,
AgentConfigResponse remoteConfig,
BackendAuthBundle auth,
@@ -420,6 +420,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
terminationHandler: terminationHandler,
remoteConfigService: remoteConfigService,
diagnosticsService: diagnosticsService,
auth: auth,
agentVersion: agentVersion,
consoleMode: consoleMode,
shutdown: shutdown,
shutdownComplete: shutdownComplete,
post: lifecyclePost,
@@ -33,6 +33,9 @@ namespace AutopilotMonitor.Agent.V2.Runtime
EnrollmentTerminationHandler terminationHandler,
RemoteConfigService remoteConfigService,
DiagnosticsPackageService diagnosticsService,
BackendAuthBundle auth,
string agentVersion,
bool consoleMode,
ManualResetEventSlim shutdown,
ManualResetEventSlim shutdownComplete,
InformationalEventPost post,
@@ -41,9 +44,31 @@ namespace AutopilotMonitor.Agent.V2.Runtime
return new ServerActionDispatcher(
configuration: agentConfig,
logger: logger,
// rotate_config = fetch AND apply. FetchConfigAsync only stores the response
// in RemoteConfigService.CurrentConfig; the projection onto the live
// AgentConfiguration (DiagnosticsUploadMode, LogLevel, SelfDestruct, ...)
// is ApplyFetchedConfig — the same path the startup fetch uses. Without it
// the portal's "enable diagnostics, then collect" flow fetched the new
// config and still ran the diagnostics gate against the stale mode.
// FetchConfigAsync never throws: a failed fetch falls back to cache/defaults
// and reports it via LastFetchOutcome — that is NOT an executed rotation.
rotateConfigAsync: async () =>
{
try { var _ = await remoteConfigService.FetchConfigAsync(); return true; }
try
{
var fetched = await remoteConfigService.FetchConfigAsync();
if (remoteConfigService.LastFetchOutcome != RemoteConfigFetchOutcome.Succeeded)
{
logger.Warning($"ServerAction rotate_config: live fetch failed (outcome={remoteConfigService.LastFetchOutcome}, {remoteConfigService.LastFetchFailureMessage}) — runtime config unchanged.");
return false;
}
var mergeResult = AgentRuntimeConfig.ApplyFetchedConfig(
agentConfig, fetched, auth, agentVersion, consoleMode, logger);
LifecycleEmitters.EmitUnrestrictedModeAuditIfChanged(post, agentConfig, mergeResult, logger);
logger.Info($"ServerAction rotate_config: applied ConfigVersion={fetched.ConfigVersion} to the running agent.");
return true;
}
catch (Exception ex) { logger.Warning($"ServerAction rotate_config failed: {ex.Message}"); return false; }
},
// On-demand request mid-enrollment — the session has no outcome yet. Null keeps
@@ -0,0 +1,104 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins <see cref="AdminIdentityResolver"/> — what makes granting a cross-tenant role "type the UPN":
/// sign-in history first (tid + oid, both from validated tokens), then UPN domain → onboarded tenant
/// (tid only, oid pinned on first sign-in), and NO answer when the history is ambiguous (same UPN under
/// two tenants — exactly the situation the binding exists for) or the domain is unknown.
/// </summary>
public class AdminIdentityResolverTests
{
private const string Upn = "msp@partner.example";
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private const string OidOld = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
private const string OidNew = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
private static AdminIdentityResolver Build(
IEnumerable<UserSignInIdentity>? signIns = null,
params (string TenantId, string Domain)[] tenants)
{
var metrics = new Mock<IMetricsRepository>();
metrics.Setup(m => m.GetSignInIdentitiesByUpnAsync(It.IsAny<string>()))
.ReturnsAsync((signIns ?? Array.Empty<UserSignInIdentity>()).ToList());
var configRepo = new Mock<IConfigRepository>();
configRepo.Setup(r => r.GetAllTenantConfigurationsAsync())
.ReturnsAsync(tenants.Select(t => new TenantConfiguration { TenantId = t.TenantId, DomainName = t.Domain }).ToList());
var configService = new TenantConfigurationService(
configRepo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
return new AdminIdentityResolver(metrics.Object, configService, NullLogger<AdminIdentityResolver>.Instance);
}
private static UserSignInIdentity SignIn(string tid, string oid, int daysAgo) => new()
{
TenantId = tid, ObjectId = oid, LastLoginAt = DateTime.UtcNow.AddDays(-daysAgo), LoginCount = 1,
};
[Fact]
public async Task SignInHistory_SingleTenant_ResolvesTidAndOid()
{
var r = await Build(new[] { SignIn(TenantA, OidOld, 3) }).ResolveAsync("MSP@Partner.Example");
Assert.NotNull(r);
Assert.Equal(TenantA, r!.TenantId);
Assert.Equal(OidOld, r.ObjectId);
Assert.Equal(ResolvedAdminIdentity.SourceSignIn, r.Source);
}
[Fact]
public async Task SignInHistory_SameTenantTwoOids_TakesMostRecent()
{
// Account re-created in the same tenant: the newer oid is the one that will sign in next.
var r = await Build(new[] { SignIn(TenantA, OidOld, 30), SignIn(TenantA, OidNew, 1) }).ResolveAsync(Upn);
Assert.Equal(OidNew, r!.ObjectId);
}
[Fact]
public async Task SignInHistory_TwoTenants_IsAmbiguous_NotResolved_EvenIfDomainMatches()
{
// The same UPN string seen under two tenants is the attack shape; never auto-pick, even when the
// domain fallback would have an answer.
var r = await Build(new[] { SignIn(TenantA, OidOld, 3), SignIn(TenantB, OidNew, 1) }, (TenantA, "partner.example"))
.ResolveAsync(Upn);
Assert.Null(r);
}
[Fact]
public async Task NoSignIns_DomainOfOnboardedTenant_ResolvesTidOnly()
{
var r = await Build(null, (TenantA, "Partner.Example"), (TenantB, "other.example")).ResolveAsync(Upn);
Assert.NotNull(r);
Assert.Equal(TenantA, r!.TenantId);
Assert.Null(r.ObjectId);
Assert.Equal(ResolvedAdminIdentity.SourceDomain, r.Source);
}
[Fact]
public async Task NoSignIns_TwoTenantsClaimTheDomain_NotResolved()
{
var r = await Build(null, (TenantA, "partner.example"), (TenantB, "partner.example")).ResolveAsync(Upn);
Assert.Null(r);
}
[Fact]
public async Task NoSignIns_UnknownDomain_NotResolved()
{
var r = await Build(null, (TenantA, "other.example")).ResolveAsync(Upn);
Assert.Null(r);
}
[Theory]
[InlineData("nodomain")]
[InlineData("trailing@")]
public async Task MalformedUpn_NotResolved(string upn)
=> Assert.Null(await Build(null, (TenantA, "partner.example")).ResolveAsync(upn));
}
@@ -96,7 +96,9 @@ public class AuthFunctionSideEffectTests
_autoApproveEnqueuerMock.Object,
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()));
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
new Mock<AdminIdentityResolver>(
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object);
// Default: all fire-and-forget calls succeed
_tenantConfigMock
@@ -0,0 +1,97 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The targeted push signal: "someone clicked Collect Logs and switched on Hosted upload
/// through the quick-config dialog" must land as its own ops event type, while every other
/// enable path keeps the generic type — an alert rule filters by EventType only.
/// </summary>
public class CollectLogsQuickConfigOpsEventTests
{
private static (OpsEventService Service, List<OpsEventEntry> Saved) Rig()
{
var saved = new List<OpsEventEntry>();
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (saved) saved.Add(e); })
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
return (new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch), saved);
}
private static DiagnosticsUploadConfigChange EnableFlip() =>
DiagnosticsUploadConfigChange.Detect(
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off" },
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "OnFailure", DiagnosticsUploadDestination = "Hosted" })!;
[Theory]
[InlineData("collect-logs", UpdateTenantConfigurationFunction.CollectLogsSource)]
[InlineData("COLLECT-LOGS", UpdateTenantConfigurationFunction.CollectLogsSource)]
[InlineData(null, "portal-put")]
[InlineData("", "portal-put")]
[InlineData("something-else", "portal-put")]
public void ResolveWriteSource_only_honours_the_allow_listed_intent(string? intent, string expected)
{
Assert.Equal(expected, UpdateTenantConfigurationFunction.ResolveWriteSource(intent));
}
[Fact]
public async Task Quick_config_enable_lands_as_its_own_event_type()
{
var (service, saved) = Rig();
await service.RecordDiagnosticsUploadConfigChangedAsync(
"t1", "contoso.com", EnableFlip(), "admin@contoso.com", UpdateTenantConfigurationFunction.CollectLogsSource);
var evt = Assert.Single(saved);
Assert.Equal("CollectLogsQuickConfigEnabled", evt.EventType);
Assert.Equal(OpsEventCategory.Tenant, evt.Category);
Assert.Contains("admin@contoso.com", evt.Message);
Assert.Contains("Hosted", evt.Message);
}
[Theory]
[InlineData("portal-put")]
[InlineData("mcp-patch")]
public async Task Other_enable_paths_keep_the_generic_event_type(string source)
{
var (service, saved) = Rig();
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", "contoso.com", EnableFlip(), "admin@contoso.com", source);
Assert.Equal("DiagnosticsUploadEnabled", Assert.Single(saved).EventType);
}
[Fact]
public async Task Quick_config_source_on_a_disable_is_the_generic_disabled_type()
{
var (service, saved) = Rig();
var disable = DiagnosticsUploadConfigChange.Detect(
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Always", DiagnosticsUploadDestination = "Hosted" },
new TenantConfiguration { TenantId = "t1", DiagnosticsUploadMode = "Off", DiagnosticsUploadDestination = "Hosted" })!;
await service.RecordDiagnosticsUploadConfigChangedAsync("t1", null, disable, "ga@x", UpdateTenantConfigurationFunction.CollectLogsSource);
Assert.Equal("DiagnosticsUploadDisabled", Assert.Single(saved).EventType);
}
}
@@ -0,0 +1,73 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The Collect Logs capability flip that feeds the DiagnosticsUploadEnabled/Disabled ops
/// events: only a change of "would an on-demand upload work" counts, never a plain edit.
/// </summary>
public class DiagnosticsUploadConfigChangeTests
{
private static TenantConfiguration Cfg(string? mode, string? destination = null, string? sas = null) => new()
{
TenantId = "t1",
DiagnosticsUploadMode = mode!,
DiagnosticsUploadDestination = destination!,
DiagnosticsBlobSasUrl = sas!,
};
[Theory]
[InlineData(null, null, null, false)]
[InlineData("Off", "Hosted", null, false)]
[InlineData("OnFailure", null, null, false)]
[InlineData("OnFailure", "Hosted", null, true)]
[InlineData("Always", "CustomerSas", "https://x.blob.core.windows.net/c?sig=1", true)]
[InlineData("Always", null, "https://x.blob.core.windows.net/c?sig=1", true)]
public void IsConfigured_mirrors_the_feature_flag_semantics(string? mode, string? dest, string? sas, bool expected)
{
Assert.Equal(expected, DiagnosticsUploadConfigChange.IsConfigured(Cfg(mode, dest, sas)));
Assert.False(DiagnosticsUploadConfigChange.IsConfigured(null));
}
[Fact]
public void Detect_quick_config_flip_reports_enabled_with_destination_and_mode()
{
// The Collect Logs quick-config dialog: Off/unset -> Hosted + OnFailure.
var change = DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure", "Hosted"));
Assert.NotNull(change);
Assert.True(change!.Enabled);
Assert.Equal("Hosted", change.Destination);
Assert.Equal("OnFailure", change.Mode);
}
[Fact]
public void Detect_missing_before_row_counts_as_not_configured()
{
var change = DiagnosticsUploadConfigChange.Detect(null, Cfg("Always", "Hosted"));
Assert.True(change?.Enabled);
}
[Fact]
public void Detect_reports_disabled_when_mode_goes_off()
{
var change = DiagnosticsUploadConfigChange.Detect(Cfg("Always", "Hosted"), Cfg("Off", "Hosted"));
Assert.NotNull(change);
Assert.False(change!.Enabled);
Assert.Equal("Off", change.Mode);
}
[Fact]
public void Detect_ignores_edits_that_keep_the_capability_state()
{
// Mode change while staying on: no flip.
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("OnFailure", "Hosted"), Cfg("Always", "Hosted")));
// Destination not yet usable in both states: no flip.
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("OnFailure")));
// Unchanged.
Assert.Null(DiagnosticsUploadConfigChange.Detect(Cfg("Off"), Cfg("Off")));
}
}
@@ -28,6 +28,14 @@ public class RevokeEnforcementTests
private static readonly StubAdminIdentityBindingService Bindings = new(bound: true);
/// <summary>Resolver stub: never consulted by the revoke/validation seams under test (loose mock ⇒ null).</summary>
private static AdminIdentityResolver Resolver() =>
new Mock<AdminIdentityResolver>(
Mock.Of<IMetricsRepository>(),
new Mock<TenantConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false }.Object,
NullLogger<AdminIdentityResolver>.Instance) { CallBase = false }.Object;
private static DelegatedAdminService BuildService(Mock<IAdminRepository> repo) =>
new(
repo.Object,
@@ -58,7 +66,7 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new DelegatedAdminManagementFunction(
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Bindings, audit.Object, signalR);
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR);
return (fn, repo, audit, signalR);
}
@@ -75,7 +83,7 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new TenantGroupManagementFunction(
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Bindings, audit.Object, signalR);
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR);
return (fn, repo, audit, signalR);
}
@@ -105,20 +113,25 @@ public class RevokeEnforcementTests
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = tenantId }, out _));
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("partner.example")]
[InlineData("11111111-1111-1111-1111")]
public void ValidateGrant_MissingOrNonGuidHomeTenant_Rejected(string? homeTenantId)
public void ValidateGrant_NonGuidHomeTenant_Rejected(string homeTenantId)
{
// The grantee's HOME tenant is what the identity binding is keyed on — without it the grant would be
// inert at best and UPN-only (the original cross-tenant hole) at worst. Required, GUID, fail-closed.
// The home tenant is OPTIONAL at the API (resolved from sign-in history / UPN domain when absent),
// but a supplied value must be a GUID — a typo must not become a binding.
var error = DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = homeTenantId }, out _);
Assert.NotNull(error);
Assert.Contains("homeTenantId", error);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void ValidateGrant_AbsentHomeTenant_IsValid_ResolvedLater(string? homeTenantId)
=> Assert.Null(DelegatedAdminManagementFunction.ValidateGrantRequest(
new GrantDelegatedAdminRequest { Upn = Upn, TenantId = TenantA, HomeTenantId = homeTenantId }, out _));
[Fact]
public void ValidateGrant_NonGuidObjectId_Rejected()
{
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
@@ -35,6 +37,8 @@ public class TenantConfigPatchServiceTests
public Mock<IConfigRepository> Repo { get; } = new();
public Mock<IConfigBackupRepository> Backup { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public Mock<IOpsEventRepository> OpsRepo { get; } = new();
public List<OpsEventEntry> OpsEvents { get; } = new();
public TenantConfigPatchService Sut { get; }
public TenantConfiguration? Current;
@@ -89,8 +93,23 @@ public class TenantConfigPatchServiceTests
Repo.Object, NullLogger<TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()));
OpsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e => { lock (OpsEvents) OpsEvents.Add(e); })
.Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var alertDispatch = new OpsAlertDispatchService(
adminConfig.Object,
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(),
NullLogger<TelegramNotificationService>.Instance),
new WebhookNotificationService(new HttpClient(),
NullLogger<WebhookNotificationService>.Instance),
NullLogger<OpsAlertDispatchService>.Instance);
var opsService = new OpsEventService(OpsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
Sut = new TenantConfigPatchService(
Repo.Object, Backup.Object, configService, Maintenance.Object,
Repo.Object, Backup.Object, configService, Maintenance.Object, opsService,
NullLogger<TenantConfigPatchService>.Instance);
}
@@ -116,6 +135,41 @@ public class TenantConfigPatchServiceTests
return o;
}
// ── Ops visibility: Collect Logs capability flip ────────────────────────
[Fact]
public async Task Patch_EnablingDiagnosticsUpload_EmitsOpsEvent_OnlyOnTheFlip()
{
var stored = Stored();
stored.DiagnosticsUploadMode = "Off";
var harness = new Harness(stored);
// Settings page / MCP: turn the capability on (mode + Hosted destination).
var on = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("diagnosticsUploadMode", "OnFailure"), ("diagnosticsUploadDestination", "Hosted")),
Ga, "mcp-patch", "enable collect logs");
Assert.True(on.Success, on.Error);
var enabled = Assert.Single(harness.OpsEvents);
Assert.Equal("DiagnosticsUploadEnabled", enabled.EventType);
Assert.Equal(TenantId, enabled.TenantId);
Assert.Contains("contoso.com", enabled.Message);
Assert.Contains("mcp-patch", enabled.Message);
// A further edit that keeps it on is not a flip.
var tweak = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("diagnosticsUploadMode", "Always")), Ga, "mcp-patch", null);
Assert.True(tweak.Success, tweak.Error);
Assert.Single(harness.OpsEvents);
// Turning it off is the opposite flip.
var off = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("diagnosticsUploadMode", "Off")), Ga, "mcp-patch", null);
Assert.True(off.Success, off.Error);
Assert.Equal(2, harness.OpsEvents.Count);
Assert.Equal("DiagnosticsUploadDisabled", harness.OpsEvents[1].EventType);
}
// ── Happy path ──────────────────────────────────────────────────────────
[Fact]
@@ -86,6 +86,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc)
=> _storage.DeleteUserActivityOlderThanAsync(cutoffUtc);
public Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
=> _storage.GetSignInIdentitiesByUpnAsync(upn);
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
@@ -24,37 +24,33 @@ public class DelegatedAdminManagementFunction
{
private readonly ILogger<DelegatedAdminManagementFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly AdminIdentityBindingService _bindings;
private readonly AdminIdentityResolver _identityResolver;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
public DelegatedAdminManagementFunction(
ILogger<DelegatedAdminManagementFunction> logger,
DelegatedAdminService delegatedAdminService,
AdminIdentityBindingService bindings,
AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
_bindings = bindings;
_identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
/// <summary>
/// GET /api/global/delegated-admins — list every delegated assignment plus the identity bindings of the
/// assigned UPNs (so the UI can show which tenant/object id each grant is actually usable from). GlobalReadOrAdmin.
/// </summary>
/// <summary>GET /api/global/delegated-admins — list every delegated assignment. GlobalReadOrAdmin.</summary>
[Function("GetDelegatedAdmins")]
[Authorize]
public async Task<HttpResponseData> GetDelegatedAdmins(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/delegated-admins")] HttpRequestData req)
{
var assignments = await _delegatedAdminService.GetAllAsync();
var bindings = await _bindings.GetAllAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { assignments, bindings });
await response.WriteAsJsonAsync(new { assignments });
return response;
}
@@ -75,18 +71,23 @@ public class DelegatedAdminManagementFunction
if (validationError != null)
return await Bad(req, validationError);
// Edition note: TARGET tenants may be any edition — an MSP on Pro may manage Community
// customers. The Pro requirement applies to the delegated admin's HOME tenant (homeTenantId,
// which also becomes the UPN's identity binding) and is enforced at resolve time
// (DelegatedAdminService.GetScopeAsync gates on the JWT tid). Grants to non-Pro-homed admins are
// simply inert. A binding conflict (UPN already homed in another tenant) is a 409, never a silent rebind.
// The UPN's identity binding (home tenant + object id) is resolved automatically — sign-in history,
// then UPN domain → onboarded tenant; the body may override it. Unresolvable ⇒ 422 and the UI asks
// the operator to pick the tenant. Edition note: TARGET tenants may be any edition — an MSP on Pro
// may manage Community customers; the Pro requirement applies to the HOME tenant and is enforced at
// resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid), so grants to non-Pro-homed
// admins are simply inert. A binding conflict (UPN already homed elsewhere) is a 409, never a silent rebind.
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body!.Upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
return await Unresolved(req);
DelegatedAdminEntry entry;
try
{
entry = await _delegatedAdminService.UpsertAsync(
body!.Upn, body.TenantId, role,
body.Upn, body.TenantId, role,
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "",
body.HomeTenantId!, body.ObjectId);
identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
@@ -100,7 +101,7 @@ public class DelegatedAdminManagementFunction
{ "Role", entry.Role },
{ "Status", entry.Status },
{ "Source", entry.Source },
{ "HomeTenantId", body.HomeTenantId!.ToLowerInvariant() },
{ "HomeTenantId", identity.Value.TenantId.ToLowerInvariant() },
});
_logger.LogInformation("Delegated grant: {Upn} -> {TenantId} ({Role}) by {By}",
@@ -228,7 +229,7 @@ public class DelegatedAdminManagementFunction
if (!Guid.TryParse(body.TenantId, out _))
return "a valid tenantId (GUID) is required";
var bindingError = IdentityBindingRequest.Validate(body.HomeTenantId, body.ObjectId);
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
return bindingError;
@@ -252,6 +253,13 @@ public class DelegatedAdminManagementFunction
await conflict.WriteAsJsonAsync(new { error });
return conflict;
}
private static async Task<HttpResponseData> Unresolved(HttpRequestData req)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
}
public class GrantDelegatedAdminRequest
@@ -260,8 +268,8 @@ public class GrantDelegatedAdminRequest
/// <summary>The MANAGED (target) tenant the UPN may read.</summary>
public string TenantId { get; set; } = string.Empty;
public string? Role { get; set; }
/// <summary>The grantee's HOME Entra tenant id (required) — the JWT tid their tokens carry; becomes the identity binding.</summary>
/// <summary>The grantee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
public string? HomeTenantId { get; set; }
/// <summary>The grantee's Entra object id (optional) — pinned on their first sign-in when omitted.</summary>
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
public string? ObjectId { get; set; }
}
@@ -143,16 +143,48 @@ public class IdentityBindingManagementFunction
/// <summary>Body of PUT global/identity-bindings/{upn}; also the shared validator for the binding fields every grant carries.</summary>
public class IdentityBindingRequest
{
/// <summary>
/// The identity a grant binds: the operator-supplied values when present, otherwise the resolver's answer.
/// Returns null when neither yields a home tenant — the endpoint answers 422 <see cref="HomeTenantUnresolvedCode"/>
/// and the UI offers a tenant picker. A supplied object id always wins over a resolved one.
/// </summary>
public static async Task<(string TenantId, string? ObjectId)?> ResolveForGrantAsync(
AdminIdentityResolver resolver, string upn, string? homeTenantId, string? objectId)
{
objectId = string.IsNullOrWhiteSpace(objectId) ? null : objectId;
if (!string.IsNullOrWhiteSpace(homeTenantId))
return (homeTenantId, objectId);
var resolved = await resolver.ResolveAsync(upn);
return resolved == null ? null : (resolved.TenantId, objectId ?? resolved.ObjectId);
}
public string? HomeTenantId { get; set; }
public string? ObjectId { get; set; }
/// <summary>Returns the 400 message, or null when the fields are valid (home tenant = GUID required; object id = GUID or absent).</summary>
public static string? Validate(string? homeTenantId, string? objectId)
{
if (string.IsNullOrWhiteSpace(homeTenantId) || !Guid.TryParse(homeTenantId, out _))
if (string.IsNullOrWhiteSpace(homeTenantId))
return "a valid homeTenantId (GUID — the Entra tenant the person signs in from) is required";
return ValidateOptional(homeTenantId, objectId);
}
/// <summary>
/// Grant-time variant: the home tenant MAY be absent (the backend resolves it from sign-in history or the
/// UPN domain — see <see cref="AdminIdentityResolver"/>); when supplied it must be a GUID, as must the object id.
/// </summary>
public static string? ValidateOptional(string? homeTenantId, string? objectId)
{
if (!string.IsNullOrWhiteSpace(homeTenantId) && !Guid.TryParse(homeTenantId, out _))
return "homeTenantId must be a GUID (the Entra tenant the person signs in from) when supplied";
if (!string.IsNullOrWhiteSpace(objectId) && !Guid.TryParse(objectId, out _))
return "objectId must be a GUID (the person's Entra object id) when supplied";
return null;
}
/// <summary>Error code the grant endpoints return (HTTP 422) when the home tenant could not be resolved automatically.</summary>
public const string HomeTenantUnresolvedCode = "HomeTenantUnresolved";
public const string HomeTenantUnresolvedMessage =
"The person's home tenant could not be resolved automatically (no previous sign-in, and the UPN domain does not belong to an onboarded tenant). Select the tenant they sign in from.";
}
@@ -29,7 +29,7 @@ public class TenantGroupManagementFunction
{
private readonly ILogger<TenantGroupManagementFunction> _logger;
private readonly DelegatedAdminService _delegatedAdminService;
private readonly AdminIdentityBindingService _bindings;
private readonly AdminIdentityResolver _identityResolver;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
@@ -38,30 +38,26 @@ public class TenantGroupManagementFunction
public TenantGroupManagementFunction(
ILogger<TenantGroupManagementFunction> logger,
DelegatedAdminService delegatedAdminService,
AdminIdentityBindingService bindings,
AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
_bindings = bindings;
_identityResolver = identityResolver;
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
}
/// <summary>
/// GET /api/global/tenant-groups — list every group with tenants + assignee count, plus the identity
/// bindings of the assigned UPNs (which tenant/object id each assignment is usable from). GlobalReadOrAdmin.
/// </summary>
/// <summary>GET /api/global/tenant-groups — list every group with tenants + assignee count. GlobalReadOrAdmin.</summary>
[Function("GetTenantGroups")]
[Authorize]
public async Task<HttpResponseData> GetTenantGroups(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/tenant-groups")] HttpRequestData req)
{
var groups = await _delegatedAdminService.GetAllGroupsAsync();
var bindings = await _bindings.GetAllAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { groups, bindings });
await response.WriteAsJsonAsync(new { groups });
return response;
}
@@ -231,9 +227,10 @@ public class TenantGroupManagementFunction
/// <summary>
/// POST /api/global/tenant-groups/{groupId}/assignees — assign a UPN to the group. GlobalAdminOnly.
/// Body: { "upn": "user@domain.com", "role": "DelegatedReader" | "DelegatedAdmin",
/// "homeTenantId": "&lt;the assignee's Entra tenant&gt;", "objectId": "&lt;optional&gt;" }.
/// homeTenantId binds the UPN to the identity that may use the assignment (409 if the UPN is already
/// bound elsewhere — rebind explicitly via global/identity-bindings).
/// "homeTenantId": "&lt;optional override&gt;", "objectId": "&lt;optional&gt;" }.
/// The UPN's identity binding is resolved automatically (sign-in history, then UPN domain → onboarded
/// tenant); 422 HomeTenantUnresolved when neither works, 409 if the UPN is already bound elsewhere
/// (rebind explicitly via global/identity-bindings).
/// </summary>
[Function("AssignTenantGroup")]
[Authorize]
@@ -252,7 +249,7 @@ public class TenantGroupManagementFunction
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
return await Bad(req, $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'");
var bindingError = IdentityBindingRequest.Validate(body.HomeTenantId, body.ObjectId);
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
return await Bad(req, bindingError);
@@ -262,12 +259,20 @@ public class TenantGroupManagementFunction
return await NotFound(req);
var upn = body.Upn.ToLowerInvariant();
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
// The service re-checks existence (covers a delete racing this assign) — skip the audit if it no-ops.
bool assigned;
try
{
assigned = await _delegatedAdminService.AssignGroupAsync(
upn, groupId, role, true, currentUpn ?? "", body.HomeTenantId!, body.ObjectId);
upn, groupId, role, true, currentUpn ?? "", identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
@@ -390,8 +395,8 @@ public class AssignGroupRequest
{
public string Upn { get; set; } = string.Empty;
public string? Role { get; set; }
/// <summary>The assignee's HOME Entra tenant id (required) — becomes the UPN's identity binding.</summary>
/// <summary>The assignee's HOME Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
public string? HomeTenantId { get; set; }
/// <summary>The assignee's Entra object id (optional) — pinned on their first sign-in when omitted.</summary>
/// <summary>The assignee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
public string? ObjectId { get; set; }
}
@@ -86,10 +86,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
// succeed right now (mode not Off + a usable destination). Members below Admin cannot
// read the full config, so this boolean is their only signal. Deliberately exposes no
// destination detail — just "would an upload work".
diagnosticsUploadConfigured =
!string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination),
diagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
// Drives the "Autopilot Device Validation disabled" dashboard banner
// (useTenantSecurityConfig).
validateAutopilotDevice = config.ValidateAutopilotDevice,
@@ -21,17 +21,32 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly ILogger<UpdateTenantConfigurationFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEventService;
public UpdateTenantConfigurationFunction(
ILogger<UpdateTenantConfigurationFunction> logger,
TenantConfigurationService configService,
IMaintenanceRepository maintenanceRepo)
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEventService)
{
_logger = logger;
_configService = configService;
_maintenanceRepo = maintenanceRepo;
_opsEventService = opsEventService;
}
/// <summary>
/// Write-path label for the session-detail Collect Logs quick-config dialog. The dialog
/// sends <c>?intent=collect-logs</c> so the backup snapshot and the ops event can name
/// the path — a plain Settings/Admin PUT is <c>portal-put</c>. Allow-listed, never echoed
/// from arbitrary input.
/// </summary>
internal const string CollectLogsIntent = "collect-logs";
internal const string CollectLogsSource = "portal-collect-logs";
internal static string ResolveWriteSource(string? intent) =>
string.Equals(intent, CollectLogsIntent, StringComparison.OrdinalIgnoreCase) ? CollectLogsSource : "portal-put";
[Function("UpdateTenantConfiguration")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", "post", Route = "config/{tenantId}")] HttpRequestData req,
@@ -183,7 +198,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.TrialGrantedBy = existingConfig.TrialGrantedBy;
// Save configuration (retention cap already enforced by ValidateModel above)
await _configService.SaveConfigurationAsync(config, "portal-put", null);
var writeSource = ResolveWriteSource(req.Query["intent"]);
await _configService.SaveConfigurationAsync(config, writeSource, null);
var changes = ConfigDiffHelper.GetChanges(existingConfig, config);
await _maintenanceRepo.LogAuditEntryAsync(
@@ -195,6 +211,15 @@ namespace AutopilotMonitor.Functions.Functions.Config
changes.Count > 0 ? changes : null
);
// Operator visibility for the Collect Logs capability flip (the quick-config
// dialog writes through this PUT). OpsEventService never throws.
var diagChange = DiagnosticsUploadConfigChange.Detect(existingConfig, config);
if (diagChange != null)
{
await _opsEventService.RecordDiagnosticsUploadConfigChangedAsync(
requestCtx.TargetTenantId, config.DomainName, diagChange, userIdentifier ?? "unknown", writeSource);
}
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
@@ -32,6 +32,7 @@ public class AuthFunction
private readonly McpUserService _mcpUserService;
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
private readonly EntraAppRegistry _appRegistry;
private readonly AdminIdentityResolver _identityResolver;
public AuthFunction(
ILogger<AuthFunction> logger,
@@ -45,9 +46,11 @@ public class AuthFunction
GlobalNotificationService globalNotificationService,
McpUserService mcpUserService,
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
EntraAppRegistry appRegistry)
EntraAppRegistry appRegistry,
AdminIdentityResolver identityResolver)
{
_logger = logger;
_identityResolver = identityResolver;
_globalAdminService = globalAdminService;
_delegatedAdminService = delegatedAdminService;
_tenantConfigService = tenantConfigService;
@@ -251,20 +254,27 @@ public class AuthFunction
await badRequestResponse.WriteAsJsonAsync(new { error = "UPN is required" });
return badRequestResponse;
}
// The grantee's home tenant is mandatory: the row is inert until the UPN is bound to the identity
// that may use it, and the UPN string alone cannot tell which tenant that is.
var bindingError = IdentityBindingRequest.Validate(body.HomeTenantId, body.ObjectId);
// The row is inert until the UPN is bound to the identity that may use it. The home tenant is resolved
// automatically (sign-in history, then UPN domain → onboarded tenant); the body may override it.
var bindingError = IdentityBindingRequest.ValidateOptional(body.HomeTenantId, body.ObjectId);
if (bindingError != null)
{
var badRequestResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequestResponse.WriteAsJsonAsync(new { error = bindingError });
return badRequestResponse;
}
var identity = await IdentityBindingRequest.ResolveForGrantAsync(_identityResolver, body.Upn, body.HomeTenantId, body.ObjectId);
if (identity == null)
{
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
return unresolved;
}
GlobalAdminEntity newAdmin;
try
{
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, body.HomeTenantId!, body.ObjectId);
newAdmin = await _globalAdminService.AddGlobalAdminAsync(body.Upn, currentUpn!, identity.Value.TenantId, identity.Value.ObjectId);
}
catch (IdentityBindingConflictException ex)
{
@@ -636,8 +646,8 @@ internal class AuthDecisionResult
public class AddGlobalAdminRequest
{
public string Upn { get; set; } = string.Empty;
/// <summary>The grantee's home Entra tenant id (required) — the JWT tid their tokens will carry.</summary>
/// <summary>The grantee's home Entra tenant id (optional override) — resolved from sign-in history / UPN domain when omitted.</summary>
public string? HomeTenantId { get; set; }
/// <summary>The grantee's Entra object id (optional) — pinned on their first sign-in when omitted.</summary>
/// <summary>The grantee's Entra object id (optional) — taken from sign-in history, else pinned on their first sign-in.</summary>
public string? ObjectId { get; set; }
}
@@ -4,6 +4,7 @@ using System.IO;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.ApplicationInsights;
@@ -24,6 +25,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
private readonly ILogger<QueueSessionActionFunction> _logger;
private readonly ISessionRepository _sessionRepo;
private readonly TelemetryClient _telemetryClient;
private readonly OpsEventService _opsEventService;
private static readonly HashSet<string> AllowedTypes = new(StringComparer.OrdinalIgnoreCase)
{
@@ -57,11 +59,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
public QueueSessionActionFunction(
ILogger<QueueSessionActionFunction> logger,
ISessionRepository sessionRepo,
TelemetryClient telemetryClient)
TelemetryClient telemetryClient,
OpsEventService opsEventService)
{
_logger = logger;
_sessionRepo = sessionRepo;
_telemetryClient = telemetryClient;
_opsEventService = opsEventService;
}
/// <summary>
@@ -131,6 +135,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
return notFound;
}
// Ops feed: who uses the on-demand session controls (Collect Logs, quick-config,
// terminate). App Insights alone is invisible to alert rules. Never throws.
await _opsEventService.RecordSessionActionQueuedAsync(
tenantId, sessionId, action.Type, action.Reason, userIdentifier ?? "unknown");
_telemetryClient.TrackEvent("ServerActionQueued", new Dictionary<string, string>
{
{ "tenantId", tenantId },
@@ -252,6 +252,7 @@ builder.Services.AddSingleton<SlaMetricsService>();
builder.Services.AddSingleton<SlaBreachEvaluationService>();
// Identity binding (tid + oid behind every cross-tenant-role UPN) — consulted by both role services.
builder.Services.AddSingleton<AdminIdentityBindingService>();
builder.Services.AddSingleton<AdminIdentityResolver>();
builder.Services.AddSingleton<GlobalAdminService>();
builder.Services.AddSingleton<DelegatedAdminService>();
builder.Services.AddSingleton<McpUserService>();
@@ -0,0 +1,85 @@
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Resolves the Entra identity (home tenant id + object id) behind a UPN at grant time WITHOUT operator
/// input, so granting a cross-tenant role is "type the UPN" and the identity binding is self-maintaining:
/// <list type="number">
/// <item><b>Sign-in history</b> (<see cref="Constants.TableNames.UserActivity"/>): the person has signed in
/// before ⇒ tid + oid are known and verified (both came from validated tokens). Ambiguous history (the same
/// UPN string seen under more than one tenant) is NOT auto-resolved — that is the very situation the binding
/// exists for, and only the operator can say which one is meant.</item>
/// <item><b>UPN domain → onboarded tenant</b>: the person has never signed in, but their UPN domain is the
/// domain of an onboarded tenant ⇒ tid is known; the oid is pinned on their first sign-in from that tenant.</item>
/// </list>
/// Neither ⇒ null; the caller asks the operator to pick the home tenant (the only manual step left, and only
/// for a UPN whose domain the platform has never seen).
/// </summary>
public class AdminIdentityResolver
{
private readonly IMetricsRepository _metricsRepo;
private readonly TenantConfigurationService _tenantConfigService;
private readonly ILogger<AdminIdentityResolver> _logger;
public AdminIdentityResolver(
IMetricsRepository metricsRepo,
TenantConfigurationService tenantConfigService,
ILogger<AdminIdentityResolver> logger)
{
_metricsRepo = metricsRepo;
_tenantConfigService = tenantConfigService;
_logger = logger;
}
public virtual async Task<ResolvedAdminIdentity?> ResolveAsync(string upn)
{
upn = upn.ToLowerInvariant();
var signIns = await _metricsRepo.GetSignInIdentitiesByUpnAsync(upn);
var tenants = signIns.Select(s => s.TenantId.ToLowerInvariant()).Distinct().ToList();
if (tenants.Count == 1)
{
// Most recent sign-in wins for the oid (a re-created account in the same tenant shows up as a
// newer oid; the older one would be refused at sign-in anyway).
var latest = signIns.OrderByDescending(s => s.LastLoginAt).First();
return new ResolvedAdminIdentity(tenants[0],
string.IsNullOrWhiteSpace(latest.ObjectId) ? null : latest.ObjectId.ToLowerInvariant(),
ResolvedAdminIdentity.SourceSignIn);
}
if (tenants.Count > 1)
{
_logger.LogWarning("[IdentityBinding] {Upn} has sign-ins from {Count} different tenants — not auto-resolving the home tenant",
upn, tenants.Count);
return null;
}
var at = upn.LastIndexOf('@');
if (at <= 0 || at == upn.Length - 1)
return null;
var domain = upn[(at + 1)..];
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
var byDomain = configs
.Where(c => !string.IsNullOrWhiteSpace(c.DomainName)
&& string.Equals(c.DomainName.Trim(), domain, StringComparison.OrdinalIgnoreCase))
.Select(c => c.TenantId.ToLowerInvariant())
.Distinct()
.ToList();
if (byDomain.Count == 1)
return new ResolvedAdminIdentity(byDomain[0], null, ResolvedAdminIdentity.SourceDomain);
return null;
}
}
/// <summary>
/// Outcome of <see cref="AdminIdentityResolver.ResolveAsync"/>: the home tenant, the object id when the
/// person has signed in before (null ⇒ pinned on first sign-in), and where the answer came from.
/// </summary>
public sealed record ResolvedAdminIdentity(string TenantId, string? ObjectId, string Source)
{
public const string SourceSignIn = "sign-in";
public const string SourceDomain = "domain";
}
@@ -0,0 +1,60 @@
using System;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// "Can an on-demand diagnostics upload succeed for this tenant?" — the single definition
/// behind the session-detail Collect Logs button (feature flag
/// <c>diagnosticsUploadConfigured</c>) and the ops-event tripwire that reports when a
/// tenant admin turns that capability on or off (portal Settings, the Collect Logs
/// quick-config dialog, MCP <c>update_tenant_config</c>, or a config revert).
/// </summary>
public sealed class DiagnosticsUploadConfigChange
{
public bool Enabled { get; }
public string? Destination { get; }
public string Mode { get; }
private DiagnosticsUploadConfigChange(bool enabled, string? destination, string mode)
{
Enabled = enabled;
Destination = destination;
Mode = mode;
}
/// <summary>
/// Mode not Off AND a usable destination (customer SAS or Hosted). Mirrors the agent's
/// own gate (<c>DiagnosticsUploadMode</c> + <c>DiagnosticsUploadEnabled</c>), so
/// "configured" here means the agent would actually build and upload a package.
/// </summary>
public static bool IsConfigured(TenantConfiguration? config)
{
if (config == null) return false;
return !string.Equals(config.DiagnosticsUploadMode ?? "Off", "Off", StringComparison.OrdinalIgnoreCase)
&& GetAgentConfigFunction.ResolveDiagnosticsUploadEnabled(
config.DiagnosticsBlobSasUrl, config.DiagnosticsUploadDestination);
}
/// <summary>
/// Returns the flip when <paramref name="after"/> changes the configured state relative
/// to <paramref name="before"/>; null when the capability did not change (including
/// destination/mode edits that keep it on). A missing <paramref name="before"/> row
/// counts as "not configured".
/// </summary>
public static DiagnosticsUploadConfigChange? Detect(TenantConfiguration? before, TenantConfiguration after)
{
if (after == null) throw new ArgumentNullException(nameof(after));
var wasConfigured = IsConfigured(before);
var isConfigured = IsConfigured(after);
if (wasConfigured == isConfigured) return null;
return new DiagnosticsUploadConfigChange(
isConfigured,
after.DiagnosticsUploadDestination,
after.DiagnosticsUploadMode ?? "Off");
}
}
}
@@ -717,8 +717,52 @@ namespace AutopilotMonitor.Functions.Services
lift,
});
/// <summary>
/// A tenant admin turned on-demand diagnostics upload on or off — the capability behind
/// the session-detail Collect Logs button (portal Settings, the Collect Logs quick-config
/// dialog, MCP update_tenant_config, or a config revert). Fires only on the flip, not on
/// every diagnostics edit (<see cref="DiagnosticsUploadConfigChange.Detect"/>).
/// Info-tier. The Collect Logs quick-config dialog (source <c>portal-collect-logs</c>)
/// gets its own type <c>CollectLogsQuickConfigEnabled</c> so a rule can push on exactly
/// "someone clicked Collect Logs and switched on Hosted upload" without also firing on
/// Settings-page edits; every other enable path is <c>DiagnosticsUploadEnabled</c>.
/// Dual-register per memory feedback_ops_event_types_dual_register.
/// </summary>
public Task RecordDiagnosticsUploadConfigChangedAsync(
string tenantId, string? domainName, DiagnosticsUploadConfigChange change, string changedBy, string source)
{
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
var quickConfig = string.Equals(source, Functions.Config.UpdateTenantConfigurationFunction.CollectLogsSource, StringComparison.Ordinal);
if (change.Enabled && quickConfig)
{
return WriteAsync(OpsEventCategory.Tenant, "CollectLogsQuickConfigEnabled", OpsEventSeverity.Info,
$"Collect Logs: {changedBy} enabled diagnostics upload for tenant {tenantLabel} via the session quick-config dialog (destination {change.Destination ?? "?"}, mode {change.Mode})",
tenantId, changedBy, new { domainName, change.Destination, change.Mode, source });
}
return change.Enabled
? WriteAsync(OpsEventCategory.Tenant, "DiagnosticsUploadEnabled", OpsEventSeverity.Info,
$"Diagnostics upload enabled for tenant {tenantLabel} (destination {change.Destination ?? "?"}, mode {change.Mode}) via {source} by {changedBy}",
tenantId, changedBy, new { domainName, change.Destination, change.Mode, source })
: WriteAsync(OpsEventCategory.Tenant, "DiagnosticsUploadDisabled", OpsEventSeverity.Info,
$"Diagnostics upload disabled for tenant {tenantLabel} (mode {change.Mode}) via {source} by {changedBy}",
tenantId, changedBy, new { domainName, change.Destination, change.Mode, source });
}
// ── Agent ──────────────────────────────────────────────────────────────
/// <summary>
/// A portal user queued a ServerAction for a live session (Collect Logs =
/// <c>request_diagnostics</c>, quick-config = <c>rotate_config</c>, admin
/// <c>terminate_session</c>). Rule-engine-queued actions do NOT pass through here —
/// this is the "who uses the on-demand controls, and how often" signal.
/// Dual-register per memory feedback_ops_event_types_dual_register.
/// </summary>
public Task RecordSessionActionQueuedAsync(string tenantId, string sessionId, string actionType, string? reason, string queuedBy)
=> WriteAsync(OpsEventCategory.Agent, "SessionActionQueued", OpsEventSeverity.Info,
$"Server action '{actionType}' queued for session {sessionId} by {queuedBy}: {reason}",
tenantId, queuedBy, new { sessionId, actionType, reason });
public Task RecordSessionTimeoutsAsync(string tenantId, int sessionCount, int timeoutHours)
=> WriteAsync(OpsEventCategory.Agent, "SessionTimeouts", OpsEventSeverity.Info,
$"{sessionCount} session(s) timed out after {timeoutHours}h",
@@ -1124,6 +1124,38 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Every distinct (tenant, object id) pair a UPN has signed in with — see IMetricsRepository. The Upn
/// column is not a key, so this is a filtered table scan; acceptable because it runs once per grant.
/// </summary>
public async Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn)
{
var result = new Dictionary<(string Tid, string Oid), UserSignInIdentity>();
if (string.IsNullOrWhiteSpace(upn))
return new List<UserSignInIdentity>();
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity);
// Rows store the UPN as the token carried it; compare lowercase in memory and pre-filter on the
// exact lowercase string (the common case) OR the raw input so a mixed-case row is not missed.
var normalized = upn.ToLowerInvariant().Replace("'", "''");
var filter = $"Upn eq '{normalized}'";
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: filter, select: new[] { "PartitionKey", "Upn", "ObjectId", "LoginAt" }))
{
var tid = entity.PartitionKey.ToLowerInvariant();
var oid = (entity.GetString("ObjectId") ?? string.Empty).ToLowerInvariant();
var loginAt = entity.GetDateTime("LoginAt") ?? DateTime.MinValue;
if (!result.TryGetValue((tid, oid), out var entry))
{
entry = new UserSignInIdentity { TenantId = tid, ObjectId = oid };
result[(tid, oid)] = entry;
}
entry.LoginCount++;
if (loginAt > entry.LastLoginAt)
entry.LastLoginAt = loginAt;
}
return result.Values.ToList();
}
/// <summary>
/// Gets user activity metrics for a specific tenant
/// </summary>
@@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly IConfigBackupRepository _backupRepo;
private readonly TenantConfigurationService _configService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEventService;
private readonly ILogger<TenantConfigPatchService> _logger;
public TenantConfigPatchService(
@@ -99,12 +100,14 @@ namespace AutopilotMonitor.Functions.Services
IConfigBackupRepository backupRepo,
TenantConfigurationService configService,
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEventService,
ILogger<TenantConfigPatchService> logger)
{
_configRepo = configRepo;
_backupRepo = backupRepo;
_configService = configService;
_maintenanceRepo = maintenanceRepo;
_opsEventService = opsEventService;
_logger = logger;
}
@@ -421,6 +424,15 @@ namespace AutopilotMonitor.Functions.Services
await _maintenanceRepo.LogAuditEntryAsync(
tenantId, auditAction, "TenantConfiguration", tenantId, updatedBy, auditDetails);
// Operator visibility for the Collect Logs capability flip — the verified re-read is
// the truth of what landed, so detect on it rather than on the candidate.
var diagChange = DiagnosticsUploadConfigChange.Detect(initial, reread.Value.Config);
if (diagChange != null)
{
await _opsEventService.RecordDiagnosticsUploadConfigChangedAsync(
tenantId, reread.Value.Config.DomainName, diagChange, updatedBy, source);
}
var applied = expected.OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList();
return new PatchOutcome(true, PatchFailure.None, null, backupId, applied, maskedDiff, null);
}
@@ -76,6 +76,12 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date);
/// <summary>Retention cleanup: deletes UserActivity login rows whose LoginAt is older than the cutoff. Returns the number deleted.</summary>
Task<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc);
/// <summary>
/// Every distinct (tenant id, object id) a UPN has signed in with, from the UserActivity login rows
/// (cross-partition scan filtered on Upn — grant-time only, never a hot path). Both values came from
/// validated tokens, which is what makes them usable as an identity-binding source.
/// </summary>
Task<List<UserSignInIdentity>> GetSignInIdentitiesByUpnAsync(string upn);
// --- Live Presence (one row per user, upserted) ---
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
@@ -148,6 +154,15 @@ namespace AutopilotMonitor.Shared.DataAccess
public string AppName { get; set; } = string.Empty;
}
/// <summary>One (tenant, object id) a UPN was seen signing in with, plus the most recent such sign-in.</summary>
public class UserSignInIdentity
{
public string TenantId { get; set; } = string.Empty;
public string ObjectId { get; set; } = string.Empty;
public DateTime LastLoginAt { get; set; }
public int LoginCount { get; set; }
}
public class UserActivityMetrics
{
/// <summary>
@@ -0,0 +1,225 @@
"use client";
import { useCallback, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { isGuid, type IdentityBinding } from "@/lib/identityBinding";
interface IdentityBindingsSectionProps {
tenantId: string;
getAccessToken: () => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
/**
* Collapsed-by-default view of the identity bindings HOMED in this tenant: every UPN that holds a
* cross-tenant role (platform GlobalAdmin/GlobalReader, delegated MSP grants) and signs in from here,
* with the Entra tenant + object id the role is bound to. Maintained automatically (resolved from sign-in
* history at grant time, object id pinned on the first sign-in); this is the inspection / correction
* surface so a re-created account or a re-homed UPN never needs a table edit.
*/
export function IdentityBindingsSection({
tenantId,
getAccessToken,
setError,
setSuccessMessage,
}: IdentityBindingsSectionProps) {
const canMutate = useCanMutatePlatform();
const [open, setOpen] = useState(false);
const [loaded, setLoaded] = useState(false);
const [bindings, setBindings] = useState<IdentityBinding[]>([]);
const [editing, setEditing] = useState<string | null>(null);
const [editTenantId, setEditTenantId] = useState("");
const [editObjectId, setEditObjectId] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const fail = useCallback(
(err: unknown, fallback: string) => {
if (err instanceof TokenExpiredError) setError(err.message);
else setError(err instanceof Error ? err.message : fallback);
},
[setError],
);
const load = useCallback(async () => {
try {
const response = await authenticatedFetch(api.identityBindings.list(), getAccessToken);
if (!response.ok) throw new Error(`Failed to load identity bindings: ${response.statusText}`);
const data = await response.json();
const all: IdentityBinding[] = data.bindings ?? [];
setBindings(
all
.filter((b) => b.tenantId.toLowerCase() === tenantId.toLowerCase())
.sort((a, b) => a.upn.localeCompare(b.upn)),
);
setLoaded(true);
} catch (err) {
fail(err, "Failed to load identity bindings");
}
}, [tenantId, getAccessToken, fail]);
// Lazy: the list is fetched the first time the panel is opened (event-driven, no effect).
const toggle = () => {
const next = !open;
setOpen(next);
if (next && !loaded) void load();
};
const startEdit = (b: IdentityBinding) => {
setEditing(b.upn);
setEditTenantId(b.tenantId);
setEditObjectId(b.objectId);
};
const save = async (upn: string) => {
const tid = editTenantId.trim();
const oid = editObjectId.trim();
if (!isGuid(tid) || (oid !== "" && !isGuid(oid))) return;
try {
setBusy(upn);
setError(null);
const response = await authenticatedFetch(api.identityBindings.put(upn), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ homeTenantId: tid, objectId: oid || undefined }),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Failed to update binding: ${response.statusText}`);
}
setSuccessMessage(`Identity binding for ${upn} updated.`);
setEditing(null);
await load();
} catch (err) {
fail(err, "Failed to update binding");
} finally {
setBusy(null);
}
};
const remove = async (upn: string) => {
if (!confirm(`Remove the identity binding for ${upn}? Every platform/delegated role of this UPN stops working until it is granted again.`)) return;
try {
setBusy(upn);
setError(null);
const response = await authenticatedFetch(api.identityBindings.remove(upn), getAccessToken, { method: "DELETE" });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Failed to remove binding: ${response.statusText}`);
}
setSuccessMessage(`Identity binding for ${upn} removed.`);
await load();
} catch (err) {
fail(err, "Failed to remove binding");
} finally {
setBusy(null);
}
};
const editValid = isGuid(editTenantId) && (editObjectId.trim() === "" || isGuid(editObjectId));
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg">
<button
type="button"
onClick={toggle}
className="w-full flex items-center justify-between px-4 py-3 text-left"
aria-expanded={open}
>
<span className="font-semibold text-gray-900">
Identity bindings
{loaded && <span className="ml-2 text-xs font-normal text-gray-500">{bindings.length}</span>}
</span>
<svg
className={`w-4 h-4 text-gray-500 transition-transform ${open ? "rotate-180" : ""}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="px-4 pb-4 space-y-2">
<p className="text-xs text-gray-500">
Cross-tenant roles (platform admins, delegated MSP admins) are bound to the Entra tenant and account they
were granted for. Bindings are maintained automatically; correct one here only if an account was re-created.
</p>
{!loaded ? (
<p className="text-sm text-gray-400">Loading…</p>
) : bindings.length === 0 ? (
<p className="text-sm text-gray-400">No cross-tenant role holders are homed in this tenant.</p>
) : (
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
{bindings.map((b) => (
<li key={b.upn} className="px-3 py-2 text-sm">
{editing === b.upn ? (
<div className="space-y-2">
<div className="font-medium text-gray-900 break-all">{b.upn}</div>
<input
type="text"
value={editTenantId}
onChange={(e) => setEditTenantId(e.target.value)}
placeholder="Home tenant ID (GUID)"
spellCheck={false}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
<input
type="text"
value={editObjectId}
onChange={(e) => setEditObjectId(e.target.value)}
placeholder="Object ID (GUID) — leave empty to re-pin on the next sign-in"
spellCheck={false}
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm font-mono text-gray-900 focus:outline-none focus:ring-2 focus:ring-sky-500"
/>
<div className="flex gap-2">
<button
onClick={() => save(b.upn)}
disabled={!editValid || busy === b.upn}
className="px-3 py-1 text-sm bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 transition-colors"
>
{busy === b.upn ? "Saving…" : "Save"}
</button>
<button
onClick={() => setEditing(null)}
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium text-gray-900 break-all">{b.upn}</span>
<span className="text-xs font-mono text-gray-500">
{b.isObjectIdPinned ? `oid ${b.objectId}` : "object id pinned on next sign-in"}
</span>
{canMutate && (
<span className="ml-auto flex gap-2 shrink-0">
<button
onClick={() => startEdit(b)}
disabled={busy === b.upn}
className="text-xs text-sky-700 hover:text-sky-900 disabled:opacity-50"
>
Edit
</button>
<button
onClick={() => remove(b.upn)}
disabled={busy === b.upn}
className="text-xs text-red-600 hover:text-red-800 disabled:opacity-50"
>
Remove
</button>
</span>
)}
</div>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -156,6 +156,17 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
"WelcomeEmailSent",
"WelcomeEmailSkipped",
"WelcomeEmailFailed",
// A tenant admin turned on-demand diagnostics upload (the Collect Logs capability) on or
// off — portal Settings, the Collect Logs quick-config dialog, MCP update_tenant_config,
// or a config revert. Fires on the flip only. Backend
// RecordDiagnosticsUploadConfigChangedAsync. Dual-register per memory
// feedback_ops_event_types_dual_register.
"DiagnosticsUploadEnabled",
"DiagnosticsUploadDisabled",
// Someone clicked Collect Logs on a session and switched on Hosted diagnostics upload
// through the quick-config dialog (PUT ?intent=collect-logs). Replaces
// DiagnosticsUploadEnabled for that path only — the targeted push signal.
"CollectLogsQuickConfigEnabled",
],
Agent: [
"BlobStorageMissing",
@@ -178,6 +189,11 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// EventIngestProcessor (RecordCmTraceTimeSkewRegressionAsync). Dual-register per memory
// feedback_ops_event_types_dual_register.
"CmTraceTimeSkewRegression",
// A portal user queued a server action for a live session — Collect Logs
// (request_diagnostics), quick-config (rotate_config) or terminate_session. Emitted by
// QueueSessionActionFunction (RecordSessionActionQueuedAsync); rule-engine actions are
// not included. Dual-register per memory feedback_ops_event_types_dual_register.
"SessionActionQueued",
],
Platform: [
// Azure Monitor alerts relayed through POST ops/alert-webhook (Common Alert Schema →
@@ -8,6 +8,7 @@ import { classifyClientId, legacyConfigured } from "@/lib/authApp";
import { appHomingErrorMessage } from "@/lib/appHoming";
import { trackEvent } from "@/lib/appInsights";
import { TenantAdminSection } from "./TenantAdminSection";
import { IdentityBindingsSection } from "./IdentityBindingsSection";
import { AppHomingConfirmDialog } from "./AppHomingConfirmDialog";
import { OffboardTenantConfirmDialog } from "./OffboardTenantConfirmDialog";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
@@ -914,6 +915,14 @@ function TenantManagementSectionInner({
setSuccessMessage={setSuccessMessage}
/>
{/* Identity bindings homed in this tenant (cross-tenant role holders) — collapsed, inspect/correct only */}
<IdentityBindingsSection
tenantId={editingTenant.tenantId}
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
/>
{/* Preview Notification Email */}
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<h3 className="font-semibold text-indigo-900 mb-3">Preview Notification Email</h3>
@@ -6,7 +6,7 @@ import { useNotifications } from "@/contexts/NotificationContext";
import { useTenantList } from "@/hooks/useTenantList";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
import { bindingLabel, bindingsByUpn, isGuid, type IdentityBinding } from "@/lib/identityBinding";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
/** One delegated-admin assignment as returned by /api/global/delegated-admins (camelCase JSON). */
interface DelegatedAssignment {
@@ -37,7 +37,6 @@ export function SectionDelegatedAdmins() {
const tenants = useTenantList(true);
const [assignments, setAssignments] = useState<DelegatedAssignment[]>([]);
const [bindings, setBindings] = useState<IdentityBinding[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
@@ -45,8 +44,10 @@ export function SectionDelegatedAdmins() {
const [newUpn, setNewUpn] = useState("");
const [newTenantId, setNewTenantId] = useState("");
const [newRole, setNewRole] = useState("DelegatedReader");
const [newHomeTenantId, setNewHomeTenantId] = useState("");
const [newObjectId, setNewObjectId] = useState("");
// Only shown after the backend answered 422 HomeTenantUnresolved (UPN never signed in AND its domain
// belongs to no onboarded tenant) — the one case the identity cannot be resolved automatically.
const [needHomeTenant, setNeedHomeTenant] = useState(false);
const [homeTenantPick, setHomeTenantPick] = useState("");
const [granting, setGranting] = useState(false);
const [busyKey, setBusyKey] = useState<string | null>(null);
@@ -75,7 +76,6 @@ export function SectionDelegatedAdmins() {
if (!response.ok) throw new Error(`Failed to load delegated admins: ${response.statusText}`);
const data = await response.json();
setAssignments(data.assignments ?? []);
setBindings(data.bindings ?? []);
} catch (err) {
handleError(err, "Failed to load delegated admins");
} finally {
@@ -91,13 +91,9 @@ export function SectionDelegatedAdmins() {
void run();
}, [fetchAssignments]);
const bindingOf = useMemo(() => bindingsByUpn(bindings), [bindings]);
const homeTenantValid = isGuid(newHomeTenantId);
const objectIdValid = newObjectId.trim() === "" || isGuid(newObjectId);
const handleGrant = useCallback(async () => {
const upn = newUpn.trim();
if (!upn || !newTenantId || !homeTenantValid || !objectIdValid) return;
if (!upn || !newTenantId || (needHomeTenant && !homeTenantPick)) return;
try {
setGranting(true);
setError(null);
@@ -108,12 +104,14 @@ export function SectionDelegatedAdmins() {
upn,
tenantId: newTenantId,
role: newRole,
homeTenantId: newHomeTenantId.trim(),
objectId: newObjectId.trim() || undefined,
homeTenantId: needHomeTenant ? homeTenantPick : undefined,
}),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED) {
setNeedHomeTenant(true);
}
throw new Error(data.error || `Failed to grant: ${response.statusText}`);
}
const dom = domainOf(newTenantId);
@@ -121,8 +119,8 @@ export function SectionDelegatedAdmins() {
setNewUpn("");
setNewTenantId("");
setNewRole("DelegatedReader");
setNewHomeTenantId("");
setNewObjectId("");
setNeedHomeTenant(false);
setHomeTenantPick("");
await fetchAssignments();
} catch (err) {
handleError(err, "Failed to grant");
@@ -130,7 +128,7 @@ export function SectionDelegatedAdmins() {
setGranting(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newUpn, newTenantId, newRole, newHomeTenantId, newObjectId, homeTenantValid, objectIdValid, getAccessToken, fetchAssignments, domainOf]);
}, [newUpn, newTenantId, newRole, needHomeTenant, homeTenantPick, getAccessToken, fetchAssignments, domainOf]);
const handleToggle = useCallback(async (a: DelegatedAssignment) => {
const key = `${a.upn}|${a.tenantId}`;
@@ -264,33 +262,24 @@ export function SectionDelegatedAdmins() {
<option value="DelegatedReader">{ROLE_LABELS.DelegatedReader}</option>
<option value="DelegatedAdmin">{ROLE_LABELS.DelegatedAdmin}</option>
</select>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<input
type="text"
value={newHomeTenantId}
onChange={(e) => setNewHomeTenantId(e.target.value)}
placeholder="Home tenant ID (GUID) — the Entra tenant they sign in from"
autoComplete="off"
spellCheck={false}
className={`flex-1 min-w-0 px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors ${
newHomeTenantId && !homeTenantValid ? "border-red-300" : "border-gray-300"
}`}
/>
<input
type="text"
value={newObjectId}
onChange={(e) => setNewObjectId(e.target.value)}
placeholder="Object ID (GUID, optional — pinned on first sign-in)"
autoComplete="off"
spellCheck={false}
className={`flex-1 min-w-0 px-4 py-2 border rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-sky-500 transition-colors ${
newObjectId && !objectIdValid ? "border-red-300" : "border-gray-300"
}`}
/>
{needHomeTenant && (
<select
value={homeTenantPick}
onChange={(e) => setHomeTenantPick(e.target.value)}
className="flex-1 min-w-0 px-3 py-2 border border-amber-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
aria-label="Home tenant of the person"
>
<option value="">Home tenant they sign in from…</option>
{tenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>
{t.domainName || t.tenantId}
</option>
))}
</select>
)}
<button
onClick={handleGrant}
disabled={granting || !newUpn.trim() || !newTenantId || !homeTenantValid || !objectIdValid}
disabled={granting || !newUpn.trim() || !newTenantId || (needHomeTenant && !homeTenantPick)}
className="px-6 py-2 bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center space-x-2"
>
{granting ? (
@@ -305,8 +294,7 @@ export function SectionDelegatedAdmins() {
</div>
<p className="text-xs text-sky-700">
Reader is read-only (secrets redacted). The tenant must be onboarded to appear in the list above.
The grant only works from the person&rsquo;s home tenant and account: a sign-in with the same UPN from
another tenant or another account is refused. Leave the object ID empty to pin it on their first sign-in.
The grant is bound to the person&rsquo;s own tenant and account automatically.
</p>
</div>
@@ -330,17 +318,11 @@ export function SectionDelegatedAdmins() {
<div className="space-y-4">
{grouped.map(([upn, rows]) => (
<div key={upn} className="border border-gray-200 rounded-lg">
<div className="px-4 py-2 border-b border-gray-100 bg-gray-50 rounded-t-lg flex flex-wrap items-center gap-x-2 gap-y-1">
<div className="px-4 py-2 border-b border-gray-100 bg-gray-50 rounded-t-lg">
<span className="font-medium text-gray-900">{upn}</span>
<span className="text-xs text-gray-500">
<span className="ml-2 text-xs text-gray-500">
{rows.length} tenant{rows.length === 1 ? "" : "s"}
</span>
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs border border-gray-300 text-gray-600"
title={bindingOf.get(upn.toLowerCase())?.tenantId ?? "No identity binding"}
>
{bindingLabel(bindingOf.get(upn.toLowerCase()), domainOf)}
</span>
</div>
<div className="divide-y divide-gray-100">
{rows.map((a) => {
@@ -6,7 +6,7 @@ import { useNotifications } from "@/contexts/NotificationContext";
import { useTenantList } from "@/hooks/useTenantList";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { api } from "@/lib/api";
import { bindingLabel, bindingsByUpn, isGuid, type IdentityBinding } from "@/lib/identityBinding";
import { HOME_TENANT_UNRESOLVED } from "@/lib/identityBinding";
/** One UPN assigned to a group (camelCase JSON from the backend). */
interface GroupAssignee {
@@ -43,7 +43,6 @@ export function SectionTenantGroups() {
const tenants = useTenantList(true);
const [groups, setGroups] = useState<TenantGroup[]>([]);
const [bindings, setBindings] = useState<IdentityBinding[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
@@ -56,8 +55,10 @@ export function SectionTenantGroups() {
const [tenantToAdd, setTenantToAdd] = useState<Record<string, string>>({});
const [assignUpn, setAssignUpn] = useState<Record<string, string>>({});
const [assignRole, setAssignRole] = useState<Record<string, string>>({});
const [assignHomeTenantId, setAssignHomeTenantId] = useState<Record<string, string>>({});
const [assignObjectId, setAssignObjectId] = useState<Record<string, string>>({});
// Per-group: shown only after a 422 HomeTenantUnresolved (UPN never signed in AND its domain belongs to
// no onboarded tenant) — the one case the identity binding cannot be resolved automatically.
const [needHomeTenant, setNeedHomeTenant] = useState<Record<string, boolean>>({});
const [homeTenantPick, setHomeTenantPick] = useState<Record<string, string>>({});
const domainOf = useMemo(() => {
const map = new Map(tenants.map((t) => [t.tenantId.toLowerCase(), t.domainName]));
@@ -84,7 +85,6 @@ export function SectionTenantGroups() {
if (!response.ok) throw new Error(`Failed to load groups: ${response.statusText}`);
const data = await response.json();
setGroups(data.groups ?? []);
setBindings(data.bindings ?? []);
} catch (err) {
handleError(err, "Failed to load groups");
} finally {
@@ -114,6 +114,9 @@ export function SectionTenantGroups() {
const response = await authenticatedFetch(url, getAccessToken, init);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
if (response.status === 422 && data.code === HOME_TENANT_UNRESOLVED && key.startsWith("assign:")) {
setNeedHomeTenant((p) => ({ ...p, [key.slice("assign:".length)]: true }));
}
throw new Error(data.error || `Request failed: ${response.statusText}`);
}
flash(ok);
@@ -188,19 +191,11 @@ export function SectionTenantGroups() {
[mutate, domainOf],
);
const bindingOf = useMemo(() => bindingsByUpn(bindings), [bindings]);
/** True when the per-group assign form is complete: UPN, GUID home tenant, and an empty-or-GUID object id. */
/** True when the per-group assign form is complete (UPN, plus the home tenant if the backend asked for it). */
const canAssign = useCallback(
(groupId: string) => {
const objectId = (assignObjectId[groupId] || "").trim();
return (
!!(assignUpn[groupId] || "").trim() &&
isGuid(assignHomeTenantId[groupId] || "") &&
(objectId === "" || isGuid(objectId))
);
},
[assignUpn, assignHomeTenantId, assignObjectId],
(groupId: string) =>
!!(assignUpn[groupId] || "").trim() && (!needHomeTenant[groupId] || !!homeTenantPick[groupId]),
[assignUpn, needHomeTenant, homeTenantPick],
);
const handleAssign = useCallback(
@@ -215,18 +210,17 @@ export function SectionTenantGroups() {
{
upn,
role,
homeTenantId: (assignHomeTenantId[t.groupId] || "").trim(),
objectId: (assignObjectId[t.groupId] || "").trim() || undefined,
homeTenantId: needHomeTenant[t.groupId] ? homeTenantPick[t.groupId] : undefined,
},
`Assigned ${upn} to "${t.name}".`,
);
if (ok) {
setAssignUpn((prev) => ({ ...prev, [t.groupId]: "" }));
setAssignHomeTenantId((prev) => ({ ...prev, [t.groupId]: "" }));
setAssignObjectId((prev) => ({ ...prev, [t.groupId]: "" }));
setNeedHomeTenant((prev) => ({ ...prev, [t.groupId]: false }));
setHomeTenantPick((prev) => ({ ...prev, [t.groupId]: "" }));
}
},
[assignUpn, assignRole, assignHomeTenantId, assignObjectId, canAssign, mutate],
[assignUpn, assignRole, needHomeTenant, homeTenantPick, canAssign, mutate],
);
const handleUnassign = useCallback(
@@ -432,12 +426,6 @@ export function SectionTenantGroups() {
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-sky-100 text-sky-800">
{ROLE_LABELS[a.role] ?? a.role}
</span>
<span
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs border border-gray-300 text-gray-600"
title={bindingOf.get(a.upn.toLowerCase())?.tenantId ?? "No identity binding"}
>
{bindingLabel(bindingOf.get(a.upn.toLowerCase()), domainOf)}
</span>
<button
onClick={() => handleUnassign(t, a.upn)}
disabled={busyKey === `unassign:${t.groupId}:${a.upn}`}
@@ -481,26 +469,21 @@ export function SectionTenantGroups() {
Assign
</button>
</div>
<div className="flex flex-wrap gap-2">
<input
type="text"
value={assignHomeTenantId[t.groupId] || ""}
onChange={(e) => setAssignHomeTenantId((p) => ({ ...p, [t.groupId]: e.target.value }))}
placeholder="Home tenant ID (GUID) — the Entra tenant they sign in from"
autoComplete="off"
spellCheck={false}
className="w-full sm:flex-1 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
/>
<input
type="text"
value={assignObjectId[t.groupId] || ""}
onChange={(e) => setAssignObjectId((p) => ({ ...p, [t.groupId]: e.target.value }))}
placeholder="Object ID (optional — pinned on first sign-in)"
autoComplete="off"
spellCheck={false}
className="w-full sm:flex-1 min-w-0 px-3 py-1.5 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 font-mono focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
/>
</div>
{needHomeTenant[t.groupId] && (
<select
value={homeTenantPick[t.groupId] || ""}
onChange={(e) => setHomeTenantPick((p) => ({ ...p, [t.groupId]: e.target.value }))}
className="w-full px-2 py-1.5 border border-amber-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors"
aria-label="Home tenant of the person"
>
<option value="">Home tenant they sign in from…</option>
{tenants.map((tn) => (
<option key={tn.tenantId} value={tn.tenantId}>
{tn.domainName || tn.tenantId}
</option>
))}
</select>
)}
</div>
</div>
</div>
@@ -145,7 +145,7 @@ export default function CollectLogsButton({
: config.diagnosticsUploadMode,
};
const putRes = await authenticatedFetch(api.config.tenant(effectiveTenantId), getAccessToken, {
const putRes = await authenticatedFetch(api.config.tenantCollectLogsQuickConfig(effectiveTenantId), getAccessToken, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updated),
+4
View File
@@ -132,6 +132,10 @@ export const api = {
config: {
all: () => `${API_BASE_URL}/api/config/all`,
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`,
// Same PUT, labelled: the session-detail Collect Logs quick-config dialog. The backend
// maps the intent to write source "portal-collect-logs" and emits the dedicated
// CollectLogsQuickConfigEnabled ops event instead of the generic DiagnosticsUploadEnabled.
tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}?intent=collect-logs`,
/**
* PATCH — transactional field-level config write (TenantAdminOrGA, own row):
* { fields: { <wireFieldName>: value | null, ... }, reason?: string }. The backend
@@ -1,33 +1,25 @@
/**
* The Entra identity (home tenant + object id) a cross-tenant-role UPN is bound to, as returned by
* /api/global/delegated-admins, /api/global/tenant-groups and /api/global/identity-bindings (camelCase JSON).
* A grant is only usable from this identity: the backend refuses tokens whose tid/oid do not match.
* /api/global/identity-bindings (camelCase JSON). A grant is only usable from this identity: the backend
* refuses tokens whose tid/oid do not match. Maintained automatically — resolved from sign-in history at
* grant time, object id pinned on the first sign-in.
*/
export interface IdentityBinding {
upn: string;
tenantId: string;
objectId: string; // "" until pinned (grant without objectId ⇒ pinned on the person's first sign-in)
objectId: string; // "" until pinned
boundBy: string;
boundAt: string;
objectIdPinnedAt: string | null;
isObjectIdPinned: boolean;
}
/** Error code the grant endpoints return (HTTP 422) when the person's home tenant could not be resolved. */
export const HOME_TENANT_UNRESOLVED = "HomeTenantUnresolved";
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** True for a canonical GUID string (what Entra tenant and object ids look like). */
export function isGuid(value: string): boolean {
return GUID_RE.test(value.trim());
}
/** Indexes a bindings list by lowercase UPN. */
export function bindingsByUpn(bindings: IdentityBinding[] | undefined): Map<string, IdentityBinding> {
return new Map((bindings ?? []).map((b) => [b.upn.toLowerCase(), b]));
}
/** Short human label for a binding's state, for the per-UPN context pill. */
export function bindingLabel(b: IdentityBinding | undefined, domainOf: (tenantId: string) => string): string {
if (!b) return "No identity binding — grant is inert";
const home = domainOf(b.tenantId) || `tenant …${b.tenantId.slice(-4)}`;
return b.isObjectIdPinned ? `${home} · object id pinned` : `${home} · object id pinned on first sign-in`;
}