feat(rbac): add read-only GlobalReader platform role (backend + MCP + web)

GlobalReader = cross-tenant READ visibility like a Global Admin, but no mutating
operations and no platform settings. Provisioned exactly like a GA: a GlobalAdmins
table row with Role=GlobalReader (empty/legacy Role = GlobalAdmin, back-compat).
Semantics are ADDITIVE — a user who is also their own tenant's Admin keeps edit
rights on that tenant; a pure GlobalReader is read-only everywhere.

Backend:
- GlobalAdmins.Role column + GlobalAdminService.GetGlobalRoleAsync; IsGlobalAdminAsync
  (incl. TableAdminRepository) delegates so a GlobalReader row never reads back as admin
- RequestContext.IsGlobalReader + HasGlobalScope
- Split GlobalAdminOnly into GlobalReadOrAdmin (cross-tenant reads) vs GlobalAdminOnly
  (writes/settings) + TenantAdminOrGlobalReader for config-read
- PolicyEnforcementMiddleware: extracted testable DecideAsync seam; cross-tenant gate keys
  off HasGlobalScope; additive own-tenant role carried so IsTenantAdmin stays correct
- Config secret redaction for readers (RedactedCopyForReader) + save-time placeholder
  restore (RestoreRedactedSecretsFrom) so a redacted view can't blank real secrets
- raw table/log tools stay GlobalAdminOnly; SignalR global-admins read group admits HasGlobalScope
- /auth/me emits isGlobalReader (+ preview-gate bypass, no auto-admin); /auth/mcp emits globalRole

MCP: hasGlobalScope (GA or Reader) drives routing + tool catalog; raw secret-bearing tools
(query_table/list_tables/query_backend_logs) gated strict-GA only.

Web: AuthContext isGlobalReader + hasGlobalScope; reader reaches /dashboard + cross-tenant
views; platform Settings/Ops/Backups/Software GA-only (nav-hidden + requireGlobalAdmin route
layouts); mutating controls on mixed data pages disabled via useCanMutatePlatform; rules pages
read-only on foreign tenants; additive own-tenant edit preserved.

Tests: backend 2369, MCP 252, web 469 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-22 19:07:10 +02:00
parent 3eabeedf48
commit 2d3853c38f
72 changed files with 1621 additions and 512 deletions
@@ -1 +1 @@
1162
1169
@@ -1 +1 @@
1459
1466
@@ -34,7 +34,7 @@ public class AuthFunctionTests
public void HappyPath_AdminUser_Returns200WithCorrectFields()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -59,7 +59,7 @@ public class AuthFunctionTests
public void HappyPath_OperatorUser_ReturnsCorrectRole()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: OperatorRole(), mcpCheck: McpDenied(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -76,7 +76,7 @@ public class AuthFunctionTests
public void HappyPath_ViewerUser_ReturnsCorrectRole()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: ViewerRole(), mcpCheck: McpDenied(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -91,7 +91,7 @@ public class AuthFunctionTests
public void HappyPath_GlobalAdmin_ReturnsIsGlobalAdminTrue()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: true, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: true, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -101,6 +101,53 @@ public class AuthFunctionTests
Assert.True((bool)body.isGlobalAdmin);
}
[Fact]
public void HappyPath_GlobalReader_ReturnsIsGlobalReaderTrue_NotGlobalAdmin()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: true, isPreviewApproved: true,
memberRole: null, mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
var body = ToDynamic(result.Body);
Assert.False((bool)body.isGlobalAdmin);
Assert.True((bool)body.isGlobalReader);
}
[Fact]
public void PreviewGate_GlobalReaderBypassesPreviewGate()
{
// A read-only platform reader is staff and must bypass the private-preview gate, exactly like a GA.
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: true, isPreviewApproved: false,
memberRole: null, mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
}
[Fact]
public void AutoAdmin_NeverTriggered_ForGlobalReader_EvenAsFirstUser()
{
// GlobalReader is additive (it never removes tenant rights) but must not SILENTLY acquire new
// write power: the first-login auto-admin convenience is declined for a read-only-flagged
// identity. (Explicit TenantAdmins membership would still grant write — that's the additive path.)
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: true, isPreviewApproved: true,
memberRole: null,
mcpCheck: McpAllowed(),
hasTenantAdmins: false, // first user in this tenant
TenantId, Upn, DisplayName, ObjectId);
Assert.True(result.IsSuccess);
Assert.False(result.NeedsAutoAdmin);
var body = ToDynamic(result.Body);
Assert.False((bool)body.isTenantAdmin);
}
// -------------------------------------------------------------------------
// Gate 1: Suspended Tenant
// -------------------------------------------------------------------------
@@ -112,7 +159,7 @@ public class AuthFunctionTests
config.Disabled = true;
var result = AuthFunction.BuildAuthResult(
config, isGlobalAdmin: false, isPreviewApproved: true,
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -131,7 +178,7 @@ public class AuthFunctionTests
config.DisabledReason = "Terms of service violation";
var result = AuthFunction.BuildAuthResult(
config, isGlobalAdmin: false, isPreviewApproved: true,
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -148,7 +195,7 @@ public class AuthFunctionTests
config.Disabled = true;
var result = AuthFunction.BuildAuthResult(
config, isGlobalAdmin: true, isPreviewApproved: true,
config, isGlobalAdmin: true, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -167,7 +214,7 @@ public class AuthFunctionTests
config.DisabledUntil = DateTime.UtcNow.AddHours(-1); // expired
var result = AuthFunction.BuildAuthResult(
config, isGlobalAdmin: false, isPreviewApproved: true,
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -183,7 +230,7 @@ public class AuthFunctionTests
public void PreviewGate_NotApproved_Returns403PrivatePreview()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: false,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -198,7 +245,7 @@ public class AuthFunctionTests
public void PreviewGate_GlobalAdminBypassesPreviewGate()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: true, isPreviewApproved: false,
DefaultConfig(), isGlobalAdmin: true, isGlobalReader: false, isPreviewApproved: false,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -214,7 +261,7 @@ public class AuthFunctionTests
public void AutoAdmin_FirstUser_NoExistingAdmins_BecomesAdmin()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: null, // no role yet
mcpCheck: McpDenied(),
hasTenantAdmins: false, // no existing admins
@@ -232,7 +279,7 @@ public class AuthFunctionTests
public void AutoAdmin_NotTriggered_WhenAdminsExist()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: null, // no role — new user but admins exist
mcpCheck: McpDenied(),
hasTenantAdmins: true, // admins already exist
@@ -252,7 +299,7 @@ public class AuthFunctionTests
// user into the TenantAdmins table. needsAutoAdmin keys off memberRole == null, not on
// "not admin", so an Entra app-role Operator in a claim-only tenant stays an Operator.
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: OperatorRole(), // came from the "roles" claim
mcpCheck: McpDenied(),
hasTenantAdmins: false, // no explicit TenantAdmins table rows
@@ -269,7 +316,7 @@ public class AuthFunctionTests
public void AutoAdmin_NotTriggered_WhenUserAlreadyAdmin()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(),
mcpCheck: McpDenied(),
hasTenantAdmins: true,
@@ -289,7 +336,7 @@ public class AuthFunctionTests
public void McpAccess_Allowed_ReturnsTrue()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpAllowed(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -302,7 +349,7 @@ public class AuthFunctionTests
public void McpAccess_Denied_ReturnsFalse()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isPreviewApproved: true,
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpDenied(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId);
@@ -322,7 +369,7 @@ public class AuthFunctionTests
config.Disabled = true;
var result = AuthFunction.BuildAuthResult(
config, isGlobalAdmin: false, isPreviewApproved: false,
config, isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: false,
memberRole: null, mcpCheck: McpDenied(),
hasTenantAdmins: false,
TenantId, Upn, DisplayName, ObjectId);
@@ -0,0 +1,140 @@
using System.Reflection;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Guards the GlobalReader secret-redaction contract on the config models. The redactors are
/// deny-lists (RedactedCopyForReader masks an explicit set of secret fields); these tests fail
/// if a NEW secret-looking string field is added to a model but not to its redactor — closing the
/// drift gap that would otherwise leak a fresh SAS/webhook/API-key field to a read-only reader.
/// </summary>
public class ConfigRedactionTests
{
// A string property whose NAME matches one of these fragments is considered secret-bearing.
private static readonly string[] SecretNameFragments =
{ "SasUrl", "WebhookUrl", "ApiKey", "CustomHeadersJson", "Secret", "Password", "BotToken" };
private static bool IsSecretName(string name)
=> SecretNameFragments.Any(f => name.Contains(f, StringComparison.OrdinalIgnoreCase));
private static IEnumerable<PropertyInfo> WritableStringProps(Type t)
=> t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.PropertyType == typeof(string) && p.CanRead && p.CanWrite);
[Fact]
public void AdminConfiguration_RedactsEverySecretLookingStringField()
{
var cfg = new AdminConfiguration();
foreach (var p in WritableStringProps(typeof(AdminConfiguration)))
p.SetValue(cfg, "POPULATED_SECRET_VALUE");
var redacted = cfg.RedactedCopyForReader();
foreach (var p in WritableStringProps(typeof(AdminConfiguration)).Where(p => IsSecretName(p.Name)))
{
Assert.Equal(Constants.RedactedSecretPlaceholder, (string?)p.GetValue(redacted));
}
}
[Fact]
public void TenantConfiguration_RedactsEverySecretLookingStringField()
{
var cfg = new TenantConfiguration();
foreach (var p in WritableStringProps(typeof(TenantConfiguration)))
p.SetValue(cfg, "POPULATED_SECRET_VALUE");
var redacted = cfg.RedactedCopyForReader();
foreach (var p in WritableStringProps(typeof(TenantConfiguration)).Where(p => IsSecretName(p.Name)))
{
Assert.Equal(Constants.RedactedSecretPlaceholder, (string?)p.GetValue(redacted));
}
}
[Fact]
public void AdminConfiguration_RedactionLeavesNonSecretsAndOriginalIntact()
{
var cfg = new AdminConfiguration { NvdApiKey = "real-key", UpdatedBy = "admin@contoso.com" };
var redacted = cfg.RedactedCopyForReader();
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.NvdApiKey);
Assert.Equal("admin@contoso.com", redacted.UpdatedBy); // non-secret preserved
Assert.Equal("real-key", cfg.NvdApiKey); // original NOT mutated
}
[Fact]
public void TenantConfiguration_RedactionLeavesNonSecretsAndOriginalIntact()
{
var cfg = new TenantConfiguration
{
DiagnosticsBlobSasUrl = "https://acct.blob.core.windows.net/c?sig=secret",
WebhookUrl = "https://hooks.example/abc",
DomainName = "contoso.com",
};
var redacted = cfg.RedactedCopyForReader();
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.DiagnosticsBlobSasUrl);
Assert.Equal(Constants.RedactedSecretPlaceholder, redacted.WebhookUrl);
Assert.Equal("contoso.com", redacted.DomainName); // non-secret preserved
Assert.Equal("https://acct.blob.core.windows.net/c?sig=secret", cfg.DiagnosticsBlobSasUrl); // original intact
}
// ── Save-guard: a redacted view round-tripped on save must NOT overwrite real secrets ──
[Fact]
public void TenantConfiguration_RestoreRedactedSecrets_RecoversRealValuesFromExisting()
{
var existing = new TenantConfiguration
{
DiagnosticsBlobSasUrl = "https://acct.blob.core.windows.net/c?sig=real",
TeamsWebhookUrl = "https://teams.example/real",
WebhookUrl = "https://hooks.example/real",
WebhookCustomHeadersJson = "{\"X-Api-Key\":\"real\"}",
DomainName = "contoso.com",
};
// Simulate the read-only reader view being saved back unchanged.
var incoming = existing.RedactedCopyForReader();
Assert.Equal(Constants.RedactedSecretPlaceholder, incoming.WebhookUrl); // sanity: it really is redacted
incoming.RestoreRedactedSecretsFrom(existing);
Assert.Equal(existing.DiagnosticsBlobSasUrl, incoming.DiagnosticsBlobSasUrl);
Assert.Equal(existing.TeamsWebhookUrl, incoming.TeamsWebhookUrl);
Assert.Equal(existing.WebhookUrl, incoming.WebhookUrl);
Assert.Equal(existing.WebhookCustomHeadersJson, incoming.WebhookCustomHeadersJson);
}
[Fact]
public void TenantConfiguration_RestoreRedactedSecrets_KeepsGenuineNewValues()
{
// A real edit (non-placeholder) must be preserved — restore only swaps the sentinel.
var existing = new TenantConfiguration { WebhookUrl = "https://hooks.example/old" };
var incoming = new TenantConfiguration
{
WebhookUrl = "https://hooks.example/NEW", // genuine change
DiagnosticsBlobSasUrl = Constants.RedactedSecretPlaceholder, // untouched in UI
};
existing.DiagnosticsBlobSasUrl = "https://acct.blob.core.windows.net/c?sig=old";
incoming.RestoreRedactedSecretsFrom(existing);
Assert.Equal("https://hooks.example/NEW", incoming.WebhookUrl); // genuine edit kept
Assert.Equal("https://acct.blob.core.windows.net/c?sig=old", incoming.DiagnosticsBlobSasUrl); // placeholder restored
}
[Fact]
public void Redaction_LeavesEmptySecretsEmpty()
{
// An unset secret stays empty (UI shows "not configured", not a misleading "***REDACTED***").
var admin = new AdminConfiguration { NvdApiKey = "" }.RedactedCopyForReader();
Assert.Equal("", admin.NvdApiKey);
var tenant = new TenantConfiguration { WebhookUrl = "" }.RedactedCopyForReader();
Assert.Equal("", tenant.WebhookUrl);
}
}
@@ -22,7 +22,7 @@ public class CrossTenantAccessTests
/// compare to JWT tenant, return whether access should be blocked.
/// </summary>
private static (bool isBlocked, string? routeTenantId) SimulateCrossTenantCheck(
string httpMethod, string requestPath, string jwtTenantId, bool isGlobalAdmin)
string httpMethod, string requestPath, string jwtTenantId, bool hasGlobalScope)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath);
if (entry == null)
@@ -40,8 +40,8 @@ public class CrossTenantAccessTests
if (string.IsNullOrEmpty(routeTenantId))
return (false, null); // no tenantId extracted → no check
if (isGlobalAdmin)
return (false, routeTenantId); // GA bypass
if (hasGlobalScope)
return (false, routeTenantId); // global-scope bypass (GA or GlobalReader)
var isCrossTenant = !string.Equals(routeTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase);
return (isCrossTenant, routeTenantId);
@@ -52,7 +52,7 @@ public class CrossTenantAccessTests
/// fall back to JWT tenant, compare against JWT tenant.
/// </summary>
private static (bool isBlocked, string targetTenantId) SimulateQueryParamCheck(
string httpMethod, string requestPath, string jwtTenantId, bool isGlobalAdmin, string? queryTenantId)
string httpMethod, string requestPath, string jwtTenantId, bool hasGlobalScope, string? queryTenantId)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath);
if (entry == null)
@@ -63,7 +63,7 @@ public class CrossTenantAccessTests
var targetTenantId = string.IsNullOrWhiteSpace(queryTenantId) ? jwtTenantId : queryTenantId;
if (isGlobalAdmin)
if (hasGlobalScope)
return (false, targetTenantId);
var isCrossTenant = !string.Equals(targetTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase);
@@ -86,7 +86,7 @@ public class CrossTenantAccessTests
public void SameTenant_NonGA_IsAllowed(string httpMethod, string pathTemplate)
{
var path = string.Format(pathTemplate, TenantA);
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck(httpMethod, path, TenantA, isGlobalAdmin: false);
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck(httpMethod, path, TenantA, hasGlobalScope: false);
Assert.False(isBlocked, $"Same-tenant access should be allowed: {httpMethod} {path}");
Assert.Equal(TenantA, routeTenantId);
@@ -108,7 +108,7 @@ public class CrossTenantAccessTests
public void CrossTenant_NonGA_IsBlocked(string httpMethod, string pathTemplate)
{
var path = string.Format(pathTemplate, TenantB); // accessing TenantB
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck(httpMethod, path, TenantA, isGlobalAdmin: false);
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck(httpMethod, path, TenantA, hasGlobalScope: false);
Assert.True(isBlocked, $"Cross-tenant access should be blocked: {httpMethod} {path}");
Assert.Equal(TenantB, routeTenantId);
@@ -124,7 +124,7 @@ public class CrossTenantAccessTests
public void CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string pathTemplate)
{
var path = string.Format(pathTemplate, TenantB); // accessing TenantB
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck(httpMethod, path, TenantA, isGlobalAdmin: true);
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck(httpMethod, path, TenantA, hasGlobalScope: true);
Assert.False(isBlocked, $"Global Admin should be allowed cross-tenant access: {httpMethod} {path}");
Assert.Equal(TenantB, routeTenantId);
@@ -139,7 +139,7 @@ public class CrossTenantAccessTests
[InlineData("GET", "/api/rules/gather")]
public void JwtImplicitRoutes_NoCrossTenantCheck(string httpMethod, string path)
{
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck(httpMethod, path, TenantA, isGlobalAdmin: false);
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck(httpMethod, path, TenantA, hasGlobalScope: false);
Assert.False(isBlocked, $"JWT-implicit route should have no cross-tenant check: {httpMethod} {path}");
Assert.Null(routeTenantId);
@@ -151,7 +151,7 @@ public class CrossTenantAccessTests
public void SameTenant_DifferentCase_IsAllowed()
{
var upperTenant = TenantA.ToUpperInvariant();
var (isBlocked, _) = SimulateCrossTenantCheck("GET", $"/api/config/{upperTenant}", TenantA, isGlobalAdmin: false);
var (isBlocked, _) = SimulateCrossTenantCheck("GET", $"/api/config/{upperTenant}", TenantA, hasGlobalScope: false);
Assert.False(isBlocked, "Tenant ID comparison should be case-insensitive");
}
@@ -162,7 +162,7 @@ public class CrossTenantAccessTests
public void AdminSubRoutes_ExtractCorrectTenantId()
{
var path = $"/api/tenants/{TenantA}/admins/user@contoso.com";
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck("DELETE", path, TenantA, isGlobalAdmin: false);
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck("DELETE", path, TenantA, hasGlobalScope: false);
Assert.False(isBlocked);
Assert.Equal(TenantA, routeTenantId);
@@ -172,7 +172,7 @@ public class CrossTenantAccessTests
public void AdminSubRoutes_CrossTenant_IsBlocked()
{
var path = $"/api/tenants/{TenantB}/admins/user@contoso.com/permissions";
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck("PATCH", path, TenantA, isGlobalAdmin: false);
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck("PATCH", path, TenantA, hasGlobalScope: false);
Assert.True(isBlocked);
Assert.Equal(TenantB, routeTenantId);
@@ -184,7 +184,6 @@ public class CrossTenantAccessTests
[InlineData("PATCH", "/api/config/{0}/plan")]
[InlineData("POST", "/api/preview/whitelist/{0}")]
[InlineData("DELETE", "/api/preview/whitelist/{0}")]
[InlineData("GET", "/api/preview/notification-email/{0}")]
[InlineData("POST", "/api/preview/send-welcome-email/{0}")]
public void GlobalAdminOnlyRoutes_WithTenantId_HaveRouteParamScoping(string httpMethod, string pathTemplate)
{
@@ -196,6 +195,39 @@ public class CrossTenantAccessTests
Assert.Equal(TenantScoping.RouteParam, entry.TenantScoping);
}
// ── GlobalReadOrAdmin routes with {tenantId} keep RouteParam scoping ──
// Cross-tenant READ surfaces opened to the read-only GlobalReader tier must still declare
// RouteParam so the middleware's HasGlobalScope gate (GA ∪ Reader) governs cross-tenant access.
[Theory]
[InlineData("GET", "/api/preview/notification-email/{0}")]
[InlineData("GET", "/api/global/tenants/{0}/deletion-manifests")]
public void GlobalReadRoutes_WithTenantId_HaveRouteParamScoping(string httpMethod, string pathTemplate)
{
var path = string.Format(pathTemplate, TenantA);
var entry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, path);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.GlobalReadOrAdmin, entry.Policy);
Assert.Equal(TenantScoping.RouteParam, entry.TenantScoping);
}
// ── Cross-tenant read by a GlobalReader (global scope, no write power) is allowed ──
// The middleware gate is HasGlobalScope (GA ∪ GlobalReader); a read-only reader crosses tenants
// for reads exactly like a GA. Modelled by hasGlobalScope:true on a read route.
[Theory]
[InlineData("GET", "/api/config/{0}")]
[InlineData("GET", "/api/preview/notification-email/{0}")]
public void CrossTenant_GlobalReader_ReadRoute_IsAllowed(string httpMethod, string pathTemplate)
{
var path = string.Format(pathTemplate, TenantB); // reader in TenantA viewing TenantB
var (isBlocked, routeTenantId) = SimulateCrossTenantCheck(httpMethod, path, TenantA, hasGlobalScope: true);
Assert.False(isBlocked, $"GlobalReader should be allowed cross-tenant read: {httpMethod} {path}");
Assert.Equal(TenantB, routeTenantId);
}
// ── QueryParam: no query param → defaults to JWT tenant ───────────
[Theory]
@@ -208,7 +240,7 @@ public class CrossTenantAccessTests
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
public void QueryParam_NoQueryTenant_DefaultsToJwt(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, isGlobalAdmin: false, queryTenantId: null);
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: false, queryTenantId: null);
Assert.False(isBlocked);
Assert.Equal(TenantA, targetTenantId);
@@ -224,7 +256,7 @@ public class CrossTenantAccessTests
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
public void QueryParam_SameTenant_IsAllowed(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, isGlobalAdmin: false, queryTenantId: TenantA);
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: false, queryTenantId: TenantA);
Assert.False(isBlocked);
Assert.Equal(TenantA, targetTenantId);
@@ -244,7 +276,7 @@ public class CrossTenantAccessTests
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
public void QueryParam_CrossTenant_NonGA_IsBlocked(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, isGlobalAdmin: false, queryTenantId: TenantB);
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: false, queryTenantId: TenantB);
Assert.True(isBlocked, $"Cross-tenant query param should be blocked: {httpMethod} {path}");
Assert.Equal(TenantB, targetTenantId);
@@ -261,7 +293,7 @@ public class CrossTenantAccessTests
[InlineData("GET", "/api/progress/sessions/abc-123/events")]
public void QueryParam_CrossTenant_GlobalAdmin_IsAllowed(string httpMethod, string path)
{
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, isGlobalAdmin: true, queryTenantId: TenantB);
var (isBlocked, targetTenantId) = SimulateQueryParamCheck(httpMethod, path, TenantA, hasGlobalScope: true, queryTenantId: TenantB);
Assert.False(isBlocked);
Assert.Equal(TenantB, targetTenantId);
@@ -159,7 +159,9 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.NotNull(getConfig);
Assert.NotNull(putConfig);
Assert.Equal(EndpointPolicy.TenantAdminOrGA, getConfig.Policy);
// GET config read is admin-tier read (own-tenant Admin / GA / read-only GlobalReader);
// PUT config write stays Admin/GA only.
Assert.Equal(EndpointPolicy.TenantAdminOrGlobalReader, getConfig.Policy);
Assert.Equal(EndpointPolicy.TenantAdminOrGA, putConfig.Policy);
var getRules = EndpointAccessPolicyCatalog.FindPolicy("GET", "/api/rules/gather");
@@ -221,46 +223,48 @@ public class EndpointPolicyCatalogCompletenessTests
}
/// <summary>
/// The three global/apps/* routes must be locked to GlobalAdminOnly with
/// TenantScoping.None (the optional ?tenantId= query param is intentional cross-tenant
/// scoping, enforced inside the function — not by the middleware).
/// A future accidental downgrade of the policy tier would be caught here, whereas
/// the generic completeness test only guarantees that *some* entry exists.
/// The three global/apps/* routes must be cross-tenant READS — GlobalReadOrAdmin (GA + read-only
/// GlobalReader) — with TenantScoping.None (the optional ?tenantId= query param is intentional
/// cross-tenant scoping, enforced inside the function — not by the middleware). A future accidental
/// downgrade below the global-scope tier (e.g. to MemberRead, exposing them to tenant members)
/// would be caught here, whereas the generic completeness test only guarantees *some* entry exists.
/// </summary>
[Theory]
[InlineData("GET", "/api/global/apps/list", "global/apps/list")]
[InlineData("GET", "/api/global/apps/Company%20Portal/analytics", "global/apps/{appName}/analytics")]
[InlineData("GET", "/api/global/apps/Company%20Portal/sessions", "global/apps/{appName}/sessions")]
public void GlobalAppsRoutes_AreGlobalAdminOnly(string method, string path, string expectedTemplate)
public void GlobalAppsRoutes_AreGlobalReadOrAdmin(string method, string path, string expectedTemplate)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
Assert.NotNull(entry);
Assert.Equal(expectedTemplate, entry!.RouteTemplate);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry.Policy);
Assert.Equal(EndpointPolicy.GlobalReadOrAdmin, entry.Policy);
Assert.Equal(TenantScoping.None, entry.TenantScoping);
}
/// <summary>
/// Inspector v1 endpoints (signals, decision-graph, reducer-verification) MUST be locked
/// to GlobalAdminOnly while the UI matures (Plan §M6 — primary use case is modelling
/// 2-stage WhiteGlove deployments). Defense-in-depth against an accidental policy
/// downgrade that would expose decision internals to tenant admins before the lift.
/// Inspector v1 endpoints (signals, decision-graph, reducer-verification) MUST stay locked to a
/// platform-scope tier while the UI matures (Plan §M6 — primary use case is modelling 2-stage
/// WhiteGlove deployments). They are now GlobalReadOrAdmin (GA + read-only GlobalReader): still
/// invisible to tenant admins/members, but visible to the read-only platform tier. Defense-in-depth
/// against an accidental downgrade to a tenant tier (MemberRead/TenantAdminOrGA) that would expose
/// decision internals before the lift.
///
/// When the v2 adminMode lift happens, signals+decision-graph move back to MemberRead
/// with TenantScoping.QueryParam. reducer-verification stays GlobalAdminOnly forever.
/// When the v2 adminMode lift happens, signals+decision-graph move to MemberRead with
/// TenantScoping.QueryParam. reducer-verification stays platform-scope (never tenant-visible).
/// </summary>
[Theory]
[InlineData("GET", "/api/sessions/abc-123/signals", "sessions/{sessionId}/signals")]
[InlineData("GET", "/api/sessions/abc-123/decision-graph", "sessions/{sessionId}/decision-graph")]
[InlineData("GET", "/api/sessions/abc-123/reducer-verification", "sessions/{sessionId}/reducer-verification")]
public void InspectorRoutes_AreGlobalAdminOnly(string method, string path, string expectedTemplate)
public void InspectorRoutes_AreGlobalReadOrAdmin(string method, string path, string expectedTemplate)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
Assert.NotNull(entry);
Assert.Equal(expectedTemplate, entry!.RouteTemplate);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, entry.Policy);
Assert.Equal(EndpointPolicy.GlobalReadOrAdmin, entry.Policy);
Assert.Equal(TenantScoping.None, entry.TenantScoping);
}
@@ -0,0 +1,265 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="PolicyEnforcementMiddleware"/> via its transport-agnostic <c>DecideAsync</c> seam
/// (authorization + cross-tenant enforcement + RequestContext construction — everything <c>Invoke</c> does
/// minus reading the request / writing the response). Uses the REAL GlobalAdminService /
/// TenantAdminsService / TenantConfigurationService — they all resolve through <see cref="IAdminRepository"/>
/// + an in-memory cache, so only the repository is mocked.
///
/// Primary purpose: lock in the ADDITIVE GlobalReader semantics in the resolved RequestContext —
/// a UPN that is both a GlobalReader and its own tenant's Admin must still surface IsTenantAdmin=true
/// (otherwise admin SignalR group joins + admin-audience notifications regress), while a pure GlobalReader
/// stays read-only. Also covers the cross-tenant gate keying off HasGlobalScope.
/// </summary>
public class PolicyEnforcementMiddlewareTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private sealed class Harness
{
public required PolicyEnforcementMiddleware Middleware { get; init; }
public required Mock<IAdminRepository> Repo { get; init; }
public void AsGlobalRole(string role) =>
Repo.Setup(r => r.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync(role);
public void AsTenantAdmin(string tenantId, string upn) =>
Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new TenantMember
{
Upn = upn,
TenantId = tenantId,
Role = Constants.TenantRoles.Admin,
IsEnabled = true,
});
}
private static Harness BuildHarness()
{
var repo = new Mock<IAdminRepository>();
// Defaults: no platform role, no tenant membership. Tests override per-case.
repo.Setup(r => r.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync((TenantMember?)null);
var cache = new MemoryCache(new MemoryCacheOptions());
var globalAdmin = new GlobalAdminService(repo.Object, cache, NullLogger<GlobalAdminService>.Instance);
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
var tenantConfig = new TenantConfigurationService(
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache);
var mw = new PolicyEnforcementMiddleware(
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, tenantAdmins, tenantConfig);
return new Harness { Middleware = mw, Repo = repo };
}
private static ClaimsPrincipal AuthedPrincipal(string tenantId, string upn)
=> new(new ClaimsIdentity(new[] { new Claim("tid", tenantId), new Claim("upn", upn) }, "TestAuth"));
// ── ADDITIVE: GlobalReader + own-tenant Admin keeps IsTenantAdmin ──────────────
[Fact]
public async Task MemberRead_GlobalReaderWhoIsAlsoTenantAdmin_KeepsIsTenantAdmin()
{
const string upn = "dual@contoso.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
h.AsTenantAdmin(TenantA, upn);
// /api/notifications is MemberRead — its audience depends on IsTenantAdmin.
var result = await h.Middleware.DecideAsync("GET", "/api/notifications", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
var rc = result.Context!;
Assert.True(rc.IsGlobalReader);
Assert.False(rc.IsGlobalAdmin);
Assert.True(rc.IsTenantAdmin); // additive: own-tenant admin survives the global-role branch
Assert.True(rc.HasGlobalScope);
}
[Fact]
public async Task AuthenticatedUserWithRole_GlobalReaderWhoIsAlsoTenantAdmin_KeepsIsTenantAdmin()
{
const string upn = "dual@contoso.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
h.AsTenantAdmin(TenantA, upn);
// SignalR group join is AuthenticatedUserWithRole — the admin notification group gates on IsTenantAdmin.
var result = await h.Middleware.DecideAsync("POST", "/api/realtime/groups/join", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsGlobalReader);
Assert.True(result.Context!.IsTenantAdmin);
}
// ── Pure roles (regression guards for the TenantRole ?? UserRole fallback) ─────
[Fact]
public async Task MemberRead_PureGlobalReader_IsNotTenantAdmin()
{
const string upn = "reader@contoso.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
// No tenant membership (default null).
var result = await h.Middleware.DecideAsync("GET", "/api/notifications", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsGlobalReader);
Assert.False(result.Context!.IsTenantAdmin); // no own-tenant role ⇒ not a tenant admin
}
[Fact]
public async Task MemberRead_PureTenantAdmin_IsTenantAdmin_NoGlobalScope()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn); // enabled Admin row, no global role
var result = await h.Middleware.DecideAsync("GET", "/api/notifications", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.True(result.Context!.IsTenantAdmin); // via the TenantRole ?? UserRole fallback (UserRole = "Admin")
Assert.False(result.Context!.IsGlobalReader);
Assert.False(result.Context!.IsGlobalAdmin);
}
// ── ADDITIVE write: GlobalReader + own-tenant Admin may still write own tenant ─
[Fact]
public async Task WriteRoute_GlobalReaderWhoIsAlsoTenantAdmin_AllowedInOwnTenant()
{
const string upn = "dual@contoso.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
h.AsTenantAdmin(TenantA, upn);
// PUT config/{tenantId} is TenantAdminOrGA (write). Route tenant == JWT tenant ⇒ own-tenant write.
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed); // additive: the tenant-admin hat still grants write in the own tenant
}
// ── Pure GlobalReader is read-only: write routes are denied ────────────────────
[Fact]
public async Task WriteRoute_PureGlobalReader_IsForbidden()
{
const string upn = "reader@contoso.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
// No tenant membership ⇒ no write path.
var result = await h.Middleware.DecideAsync("PUT", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
}
// ── config GET (TenantAdminOrGlobalReader): own-tenant admin view vs reader view ──
// GetTenantConfigurationFunction redacts only when IsGlobalReader && !(IsTenantAdmin && Target==Tenant).
// These lock in the RequestContext that drives that decision.
[Fact]
public async Task ConfigGet_GlobalReaderWhoIsOwnTenantAdmin_OwnTenant_IsFullView()
{
const string upn = "dual@contoso.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
h.AsTenantAdmin(TenantA, upn);
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
var rc = result.Context!;
Assert.True(rc.IsTenantAdmin);
Assert.Equal(rc.TenantId, rc.TargetTenantId); // own tenant ⇒ ownTenantAdminView ⇒ FULL config (not redacted)
}
[Fact]
public async Task ConfigGet_GlobalReaderCrossTenant_IsReaderView()
{
const string upn = "dual@contoso.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
h.AsTenantAdmin(TenantA, upn); // admin of their OWN tenant (A); role resolves against JWT tenant
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
var rc = result.Context!;
Assert.True(rc.IsGlobalReader);
Assert.True(rc.IsTenantAdmin); // admin of A…
Assert.NotEqual(rc.TenantId, rc.TargetTenantId); // …but viewing B ⇒ reader view ⇒ REDACTED
Assert.Equal(TenantB, rc.TargetTenantId);
}
// ── Cross-tenant read: GlobalReader crosses tenants (HasGlobalScope gate) ───────
[Fact]
public async Task CrossTenantRead_GlobalReader_AllowedAndTargetsForeignTenant()
{
const string upn = "reader@contoso.com";
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
// Reader in TenantA reading TenantB's config (RouteParam). HasGlobalScope ⇒ cross-tenant allowed.
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.True(result.Allowed);
Assert.Equal(TenantB, result.Context!.TargetTenantId);
}
[Fact]
public async Task CrossTenantRead_PureTenantAdmin_IsForbidden()
{
const string upn = "admin@contoso.com";
var h = BuildHarness();
h.AsTenantAdmin(TenantA, upn); // admin of A only, no global scope
// Admin of TenantA trying to read TenantB's config → cross-tenant blocked.
var result = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
Assert.Equal("CrossTenantAccessDenied", result.ErrorCode);
}
// ── Unauthenticated / unregistered ─────────────────────────────────────────────
[Fact]
public async Task MemberRead_NoPrincipal_IsUnauthorized()
{
var h = BuildHarness();
var result = await h.Middleware.DecideAsync("GET", "/api/notifications", null, principal: null);
Assert.False(result.Allowed);
Assert.Equal(401, result.StatusCode);
}
[Fact]
public async Task UnregisteredRoute_IsForbidden()
{
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var result = await h.Middleware.DecideAsync("GET", "/api/does-not-exist", null, AuthedPrincipal(TenantA, "ga@contoso.com"));
Assert.False(result.Allowed);
Assert.Equal(403, result.StatusCode);
Assert.Equal("Forbidden", result.ErrorCode);
}
}
@@ -31,25 +31,47 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
// --- Global Admins ---
public async Task<bool> IsGlobalAdminAsync(string upn)
{
// Delegate to the role resolver so the GlobalAdmin/GlobalReader distinction is enforced at the
// repository contract too: an enabled row with Role=GlobalReader is NOT a Global Admin. (Resolving
// this only in GlobalAdminService would leave a direct repo caller as a silent privilege-escalation
// footgun — a GlobalReader row reading back as "admin".)
return await GetGlobalRoleAsync(upn) == Constants.GlobalRoles.GlobalAdmin;
}
public async Task<string?> GetGlobalRoleAsync(string upn)
{
if (string.IsNullOrWhiteSpace(upn))
return false;
return null;
try
{
var normalizedUpn = upn.ToLowerInvariant();
var entity = await _globalAdminsTableClient.GetEntityAsync<GlobalAdminEntity>(
"GlobalAdmins", normalizedUpn);
return entity?.Value != null && entity.Value.IsEnabled;
if (entity?.Value == null || !entity.Value.IsEnabled)
return null;
// Empty/missing Role on an existing enabled row ⇒ GlobalAdmin (back-compat). An
// unrecognized Role string is treated as no role (fail-closed) rather than silently
// granting GlobalAdmin.
var role = entity.Value.Role;
if (string.IsNullOrWhiteSpace(role))
return Constants.GlobalRoles.GlobalAdmin;
if (role == Constants.GlobalRoles.GlobalAdmin || role == Constants.GlobalRoles.GlobalReader)
return role;
_logger.LogWarning("Unrecognized global Role '{Role}' for {Upn} — treating as no role", role, upn);
return null;
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return false;
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking global admin status for {Upn}", upn);
return false;
_logger.LogError(ex, "Error resolving global role for {Upn}", upn);
return null;
}
}
@@ -64,7 +86,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
Upn = entity.Upn,
IsEnabled = entity.IsEnabled,
AddedAt = entity.AddedDate,
AddedBy = entity.AddedBy
AddedBy = entity.AddedBy,
Role = string.IsNullOrWhiteSpace(entity.Role) ? Constants.GlobalRoles.GlobalAdmin : entity.Role
});
}
return admins;
@@ -28,16 +28,20 @@ namespace AutopilotMonitor.Functions.Functions.Config
{
try
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
string userIdentifier = TenantHelper.GetUserIdentifier(req);
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
string userIdentifier = requestCtx.UserPrincipalName;
_logger.LogInformation($"GetAdminConfiguration by Global Admin user {userIdentifier}");
_logger.LogInformation("GetAdminConfiguration by {User} (role={Role})", userIdentifier, requestCtx.UserRole);
var config = await _adminConfigService.GetConfigurationAsync();
// SECURITY NOTE: Returns full AdminConfiguration including NvdApiKey.
// Acceptable because this endpoint is GlobalAdminOnly (single user during preview).
// Consider masking NvdApiKey to a write-only sentinel before GA release.
// A read-only GlobalReader gets secrets (NvdApiKey, SAS, ops webhook URLs) redacted; a
// Global Admin gets the full config. Redaction returns a copy — the cached instance is
// never mutated.
if (requestCtx.IsGlobalReader)
config = config.RedactedCopyForReader();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(config);
return response;
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using System.Web;
@@ -47,9 +48,15 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Web consumers (tenant selectors + admin config editor) depend on this shape.
if (parsed.PageSize == null)
{
_logger.LogInformation($"GetAllTenantConfigurations (full) by Global Admin {userIdentifier}");
_logger.LogInformation($"GetAllTenantConfigurations (full) by {userIdentifier}");
var configurations = await _configService.GetAllConfigurationsAsync();
// A read-only GlobalReader gets per-tenant secrets (SAS / webhook URLs / custom
// headers) redacted; a Global Admin gets the full configs. The paginated mode below
// is already a secret-stripped keep-list projection, so it needs no role branch.
if (req.GetRequestContext().IsGlobalReader)
configurations = configurations.Select(c => c.RedactedCopyForReader()).ToList();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(configurations);
return response;
@@ -37,9 +37,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// NOTE: Returns full TenantConfiguration including DiagnosticsBlobSasUrl.
// Intentional — tenant admins need to view/manage the SAS URL in the Settings UI.
// Access restricted to TenantAdminOrGA by PolicyEnforcementMiddleware.
// Tenant admins (own tenant) and Global Admins get the full config including
// DiagnosticsBlobSasUrl / webhook URLs / custom headers — they manage those in the
// Settings UI. Secrets are redacted ONLY for the read-only-reader view (returns a copy;
// the cached instance is never mutated). ADDITIVE: a GlobalReader who is ALSO this
// tenant's own Admin is acting as the tenant admin here, so they keep the FULL config —
// redaction applies only when they are NOT the own-tenant admin (i.e. pure reader, or a
// reader viewing a DIFFERENT tenant cross-tenant). This also prevents a "***REDACTED***"
// placeholder from ever reaching the Settings save round-trip for an own-tenant admin.
var ownTenantAdminView = requestCtx.IsTenantAdmin
&& string.Equals(requestCtx.TargetTenantId, requestCtx.TenantId, StringComparison.OrdinalIgnoreCase);
if (requestCtx.IsGlobalReader && !ownTenantAdminView)
config = config.RedactedCopyForReader();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(config);
return response;
@@ -63,6 +63,16 @@ namespace AutopilotMonitor.Functions.Functions.Config
return badRequest;
}
// Load the stored config up-front so we can (1) restore any redacted secret placeholders
// before validation/save and (2) protect GA-only fields below.
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// Defense-in-depth: a read-only GlobalReader is served a redacted config (secrets replaced
// with the ***REDACTED*** sentinel). If such a view is ever round-tripped back on a save,
// never persist the placeholder — restore the real secret from the stored config. (Own-tenant
// admins are served the FULL config so this is normally a no-op for them.)
config.RestoreRedactedSecretsFrom(existingConfig);
// Validate webhook URLs (SSRF protection)
var webhookUrlError = SsrfGuard.ValidateWebhookUrlFormat(config.WebhookUrl);
if (webhookUrlError != null)
@@ -109,8 +119,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
// Set the actual user identifier for audit logging
config.UpdatedBy = userIdentifier;
// Protect GA-only fields from non-Global-Admin callers
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// Protect GA-only fields from non-Global-Admin callers (existingConfig loaded above).
if (!requestCtx.IsGlobalAdmin)
{
if (config.AllowInsecureAgentRequests != existingConfig.AllowInsecureAgentRequests ||
@@ -86,17 +86,19 @@ public class AuthFunction
// Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500.
// This is intentional — all 6 queries are required for the auth decision.
var tenantConfigTask = _tenantConfigService.GetConfigurationAsync(tenantId);
var isGlobalAdminTask = _globalAdminService.IsGlobalAdminAsync(upn);
var globalRoleTask = _globalAdminService.GetGlobalRoleAsync(upn);
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn);
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
await Task.WhenAll(tenantConfigTask, isGlobalAdminTask, isApprovedTask,
await Task.WhenAll(tenantConfigTask, globalRoleTask, isApprovedTask,
membershipTask, mcpCheckTask, existingAdminsTask);
var tenantConfig = tenantConfigTask.Result;
var isGlobalAdmin = isGlobalAdminTask.Result;
var globalRole = globalRoleTask.Result;
var isGlobalAdmin = globalRole == Constants.GlobalRoles.GlobalAdmin;
var isGlobalReader = globalRole == Constants.GlobalRoles.GlobalReader;
var isApproved = isApprovedTask.Result;
var (tableState, tableRole) = membershipTask.Result;
var mcpCheck = mcpCheckTask.Result;
@@ -114,7 +116,7 @@ public class AuthFunction
// --- Pure decision logic (tested by AuthFunctionTests) ---
var decision = BuildAuthResult(
tenantConfig, isGlobalAdmin, isApproved,
tenantConfig, isGlobalAdmin, isGlobalReader, isApproved,
memberRole, mcpCheck, existingAdmins.Count > 0,
tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty);
@@ -339,6 +341,7 @@ public class AuthFunction
internal static AuthDecisionResult BuildAuthResult(
TenantConfiguration tenantConfig,
bool isGlobalAdmin,
bool isGlobalReader,
bool isPreviewApproved,
MemberRoleInfo? memberRole,
McpAccessCheckResult mcpCheck,
@@ -359,8 +362,8 @@ public class AuthFunction
});
}
// Gate 2: Preview gate (Global Admins bypass)
if (!isGlobalAdmin && !isPreviewApproved)
// Gate 2: Preview gate (platform roles — GlobalAdmin and read-only GlobalReader — bypass)
if (!isGlobalAdmin && !isGlobalReader && !isPreviewApproved)
{
return AuthDecisionResult.Blocked(HttpStatusCode.Forbidden, new
{
@@ -372,8 +375,14 @@ public class AuthFunction
// Determine admin status: auto-admin if first user (no existing admins and no role yet).
// needsAutoAdmin keys off the absence of ANY effective role (table or claim) so a
// claim-derived Admin/Operator in a claim-only tenant is NOT written into the table.
// GlobalReader semantics are ADDITIVE (the role adds cross-tenant read; it never removes a
// user's independent tenant-role write rights). Auto-admin, however, is not an existing right —
// it SILENTLY grants write on first login. We decline that for a read-only-flagged identity:
// it removes nothing, and a GlobalReader who should also administer their tenant can be added
// to TenantAdmins explicitly. (A pure GlobalReader stays read-only everywhere via the write
// evaluators denying a roleless caller.)
bool isTenantAdmin = memberRole?.Role == Constants.TenantRoles.Admin;
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins;
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader;
if (needsAutoAdmin)
{
isTenantAdmin = true;
@@ -391,6 +400,7 @@ public class AuthFunction
displayName,
objectId,
isGlobalAdmin,
isGlobalReader,
isTenantAdmin,
role,
canManageBootstrapTokens,
@@ -173,14 +173,18 @@ public class McpUserFunction
["accessGrant"] = result.AccessGrant,
["reason"] = result.Reason,
};
// Only surface the Global-Admin flag when it is actually true. A normal
// tenant user gets no isGlobalAdmin field at all: the MCP access-guard
// reads `=== true` (absent → non-GA), and we avoid hinting to ordinary
// callers that a Global-Admin tier even exists.
// Only surface platform-role flags when the caller actually has one. A normal tenant user gets
// neither field: the MCP access-guard reads `globalRole` to decide cross-tenant routing (and
// keeps reading `isGlobalAdmin === true` for back-compat / write-tier hints), and we avoid
// hinting to ordinary callers that a platform tier even exists.
if (result.IsGlobalAdmin)
{
payload["isGlobalAdmin"] = true;
}
if (!string.IsNullOrEmpty(result.GlobalRole))
{
payload["globalRole"] = result.GlobalRole; // "GlobalAdmin" | "GlobalReader"
}
await response.WriteAsJsonAsync(payload);
return response;
}
@@ -54,17 +54,20 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// Group names are in format: "tenant-{tenantId}", "session-{tenantId}-{sessionId}", or "global-admins"
// Users can only join groups for their own tenant (unless they are Global Admin)
// Explicit validation for the global-admins group
// Explicit validation for the global-admins group. This is a READ broadcast group
// (cross-tenant new-session/new-event + global-notification live pushes), so any platform
// scope — Global Admin OR the read-only Global Reader — may join. Mutating actions remain
// gated elsewhere (the reader only RECEIVES pushes).
if (request.GroupName == "global-admins")
{
if (!requestCtx.IsGlobalAdmin)
if (!requestCtx.HasGlobalScope)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join global-admins group without being a Global Admin");
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join global-admins group without platform scope");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only Global Admins can join this group" });
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: platform scope (Global Admin or Global Reader) required to join this group" });
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
}
_logger.LogInformation($"Global Admin {userEmail} joining global-admins group");
_logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) joining global-admins group");
}
else
{
@@ -77,11 +80,13 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
return new AddToGroupOutput { HttpResponse = badRequestResponse };
}
// Check if user is allowed to join this tenant's group
// Check if user is allowed to join this tenant's group. Cross-tenant joins require
// platform scope (Global Admin OR read-only Global Reader) — both have cross-tenant
// READ scope, and group membership only RECEIVES live updates. The admin/member
// notification-group checks below still gate by the real tenant role.
if (requestedTenantId != userTenantId)
{
// Check if user is Global Admin (they can join any tenant's group)
if (!requestCtx.IsGlobalAdmin)
if (!requestCtx.HasGlobalScope)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to join group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -90,7 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
}
else
{
_logger.LogInformation($"Global Admin {userEmail} joining cross-tenant group: {request.GroupName}");
_logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) joining cross-tenant group: {request.GroupName}");
}
}
@@ -54,17 +54,18 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// Group names are in format: "tenant-{tenantId}", "session-{tenantId}-{sessionId}", or "global-admins"
// Users can only leave groups for their own tenant (unless they are Global Admin)
// Explicit validation for the global-admins group
// Explicit validation for the global-admins group — symmetric with AddToGroup: any
// platform scope (Global Admin OR read-only Global Reader) that could join may also leave.
if (request.GroupName == "global-admins")
{
if (!requestCtx.IsGlobalAdmin)
if (!requestCtx.HasGlobalScope)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave global-admins group without being a Global Admin");
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave global-admins group without platform scope");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only Global Admins can leave this group" });
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: platform scope (Global Admin or Global Reader) required for this group" });
return new RemoveFromGroupOutput { HttpResponse = forbiddenResponse };
}
_logger.LogInformation($"Global Admin {userEmail} leaving global-admins group");
_logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) leaving global-admins group");
}
else
{
@@ -77,11 +78,11 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
return new RemoveFromGroupOutput { HttpResponse = badRequestResponse };
}
// Check if user is allowed to leave this tenant's group
// Check if user is allowed to leave this tenant's group — platform scope (GA or
// read-only Global Reader) may leave any tenant's group, symmetric with AddToGroup.
if (requestedTenantId != userTenantId)
{
// Check if user is Global Admin (they can leave any tenant's group)
if (!requestCtx.IsGlobalAdmin)
if (!requestCtx.HasGlobalScope)
{
_logger.LogWarning($"User {userEmail} (tenant {userTenantId}) attempted to leave group for tenant {requestedTenantId}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
@@ -90,7 +91,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
}
else
{
_logger.LogInformation($"Global Admin {userEmail} leaving cross-tenant group: {request.GroupName}");
_logger.LogInformation($"Platform-scope user {userEmail} (role={requestCtx.UserRole}) leaving cross-tenant group: {request.GroupName}");
}
}
@@ -35,7 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var response = req.CreateResponse(HttpStatusCode.OK);
if (requestCtx.IsGlobalAdmin)
if (requestCtx.HasGlobalScope)
{
await response.WriteAsJsonAsync(versions);
}
@@ -99,7 +99,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
var records = await _userUsageRepo.GetUsageByUserAsync(userId, dateFrom, dateTo);
var ctx = req.GetRequestContext();
if (UsageCrossTenantGuard.IsForeignTenantAccess(records, ctx.TenantId, ctx.IsGlobalAdmin))
if (UsageCrossTenantGuard.IsForeignTenantAccess(records, ctx.TenantId, ctx.HasGlobalScope))
{
var foundTenants = records
.Select(r => r.TenantId)
@@ -44,9 +44,9 @@ namespace AutopilotMonitor.Functions.Functions.Rules
var requestCtx = req.GetRequestContext();
var effectiveTenantId = requestCtx.TargetTenantId;
// Global Admin cross-tenant fallback: resolve actual tenant upfront
// (needed for both read and write paths)
if (requestCtx.IsGlobalAdmin)
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve actual tenant
// upfront so the read works cross-tenant for any platform-scope caller.
if (requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
@@ -56,9 +56,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
var transitions = await _transitionRepo.QueryBySessionAsync(
requestCtx.TargetTenantId, sessionId, MaxTransitionsToLoad);
// Global Admin cross-tenant fallback (same pattern as other session-detail reads).
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback (same pattern as other session-detail reads).
var effectiveTenantId = requestCtx.TargetTenantId;
if (transitions.Count == 0 && requestCtx.IsGlobalAdmin)
if (transitions.Count == 0 && requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null &&
@@ -87,7 +87,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
// "global" sentinel; preserved so existing callers without ?tenantId still work.
var explicitTenantId = query["tenantId"];
string tenantId;
if (requestCtx.IsGlobalAdmin
if (requestCtx.HasGlobalScope
&& !string.IsNullOrEmpty(explicitTenantId)
&& Guid.TryParse(explicitTenantId, out _))
{
@@ -101,7 +101,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
// Legacy unpaginated path — full list, no nextLink.
var events = await _sessionRepo.GetSessionEventsAsync(requestCtx.TargetTenantId, sessionId);
if (events.Count == 0 && requestCtx.IsGlobalAdmin)
if (events.Count == 0 && requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
@@ -131,15 +131,15 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
// Paginated path. Continuation token binds (tenantId, sessionId), so the
// tenantId on every page must match the tenant the token was issued for.
//
// For Global Admin cross-tenant, the nextLink we emit echoes that tenant
// back as ?tenantId=, so follow-up pages can re-bind to it. On page 1, if
// GA hasn't passed tenantId yet, we resolve it from the session lookup.
// For a global-scope caller (GA or read-only GlobalReader) cross-tenant, the nextLink we
// emit echoes that tenant back as ?tenantId=, so follow-up pages can re-bind to it. On
// page 1, if no tenantId was passed yet, we resolve it from the session lookup.
//
// For non-GA, ?tenantId= in the URL is ignored — middleware-bound JWT
// For non-global callers, ?tenantId= in the URL is ignored — middleware-bound JWT
// tenant is authoritative; we must never let a query param override it.
var effectiveTenantId = requestCtx.TargetTenantId;
if (requestCtx.IsGlobalAdmin)
if (requestCtx.HasGlobalScope)
{
if (!string.IsNullOrEmpty(tenantIdQueryParam))
{
@@ -46,9 +46,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
var session = await _sessionRepo.GetSessionAsync(requestCtx.TargetTenantId, sessionId);
// Global Admin cross-tenant fallback: if not found in the effective tenant,
// try resolving the actual tenant via SessionsIndex
if (session == null && requestCtx.IsGlobalAdmin)
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: if not found in the
// effective tenant, try resolving the actual tenant via SessionsIndex
if (session == null && requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
@@ -76,11 +76,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
var transitions = await _transitionRepo.QueryBySessionAsync(
tenantIdForLoad, sessionId, MaxTransitionsToLoad);
// Global Admin cross-tenant fallback — same pattern as the other session
// detail read endpoints. Resolve via SessionsIndex when the effective tenant
// has no data. (GlobalAdminOnly gate means we don't need to check IsGlobalAdmin
// again, but the SessionsIndex lookup is still the cleanest way to find the
// actual tenant for a loose sessionId.)
// Global-scope cross-tenant fallback — same pattern as the other session detail read
// endpoints. Resolve via SessionsIndex when the effective tenant has no data. (The
// GlobalReadOrAdmin gate already restricts this route to platform-scope callers — GA or
// read-only GlobalReader — so we don't re-check the role here; the SessionsIndex lookup
// is the cleanest way to find the actual tenant for a loose sessionId.)
if (signals.Count == 0 && transitions.Count == 0)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
@@ -62,8 +62,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
var signals = await _signalRepo.QueryBySessionAsync(
requestCtx.TargetTenantId, sessionId, maxResults);
// Global Admin cross-tenant fallback — mirror GetSessionEventsFunction pattern.
if (signals.Count == 0 && requestCtx.IsGlobalAdmin)
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback — mirror GetSessionEventsFunction pattern.
if (signals.Count == 0 && requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null &&
@@ -52,8 +52,9 @@ public class QuickSearchSessionsFunction
return await req.BadRequestAsync("Query parameter 'q' must be at least 2 characters.");
}
// Global Admins search across all tenants; regular users are scoped to their own tenant
string? tenantId = requestCtx.IsGlobalAdmin ? null : requestCtx.TenantId;
// Global-scope callers (GA or read-only GlobalReader) search across all tenants;
// regular users are scoped to their own tenant.
string? tenantId = requestCtx.HasGlobalScope ? null : requestCtx.TenantId;
var results = await _sessionRepo.QuickSearchSessionsAsync(tenantId, q, limit: 10);
@@ -51,9 +51,9 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
var userIdentifier = requestCtx.UserPrincipalName;
var effectiveTenantId = requestCtx.TargetTenantId;
// Global Admin cross-tenant fallback: resolve actual tenant upfront
// (needed for both read and write paths)
if (requestCtx.IsGlobalAdmin)
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve actual tenant
// upfront so the read works cross-tenant for any platform-scope caller.
if (requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
@@ -19,9 +19,25 @@ public sealed record RequestContext
/// <summary>The user's UPN (from JWT upn/preferred_username claim).</summary>
public string UserPrincipalName { get; init; } = string.Empty;
/// <summary>True if the user is a Global Admin of the platform.</summary>
/// <summary>True if the user is a Global Admin of the platform (full read + write).</summary>
public bool IsGlobalAdmin { get; init; }
/// <summary>
/// True if the user holds the Global Reader platform role: cross-tenant read visibility equal to a
/// Global Admin but conferring no platform write/settings capability. Mutually exclusive with
/// <see cref="IsGlobalAdmin"/>. Semantics are ADDITIVE — this role grants read and never removes a
/// user's independent tenant-role rights, so a UPN that is both a Global Reader and a Tenant Admin of
/// its own tenant still passes the tenant-scoped write tiers there (the write evaluators resolve the
/// tenant role after the GA check). A pure Global Reader (no tenant role) is read-only everywhere.
/// </summary>
public bool IsGlobalReader { get; init; }
/// <summary>
/// True when the caller has platform-wide (cross-tenant) read scope — Global Admin OR Global Reader.
/// Use for read/visibility decisions and cross-tenant gating; use <see cref="IsGlobalAdmin"/> for writes.
/// </summary>
public bool HasGlobalScope => IsGlobalAdmin || IsGlobalReader;
/// <summary>True if the user is a Tenant Admin of their own tenant.</summary>
public bool IsTenantAdmin { get; init; }
@@ -67,7 +83,7 @@ public static class RequestContextExtensions
/// the role is not resolved and this returns false.
/// </summary>
public static bool IsTenantMemberOrGlobalAdmin(this RequestContext ctx)
=> ctx.IsGlobalAdmin
=> ctx.HasGlobalScope
|| ctx.UserRole == Constants.TenantRoles.Admin
|| ctx.UserRole == Constants.TenantRoles.Operator
|| ctx.UserRole == Constants.TenantRoles.Viewer;
@@ -52,31 +52,24 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var requestPath = httpContext.Request.Path.Value ?? string.Empty;
var httpMethod = httpContext.Request.Method;
var queryTenantId = httpContext.Request.Query["tenantId"].FirstOrDefault();
// Look up the catalog policy for this route
var catalogEntry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath);
if (catalogEntry == null)
// Authenticated principal set by AuthenticationMiddleware (null on anonymous/device routes).
// Read from FunctionContext.Items (reliable in the isolated worker) rather than httpContext.User.
ClaimsPrincipal? principal = null;
if (context.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal cp
&& cp.Identity?.IsAuthenticated == true)
{
// Fail-closed: unregistered route → 403
_logger.LogError("[PolicyEnforcement] BLOCKED unregistered route: {Method} {Path} — not in catalog (fail-closed)",
httpMethod, requestPath);
httpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new
{
error = "Forbidden",
message = "Access denied."
});
return;
principal = cp;
}
// Evaluate the catalog policy for the current user.
// All authorization + RequestContext construction is the transport-agnostic DecideAsync seam.
// Fail-closed on service errors: return 503 (not 500) so clients can retry.
CatalogDecisionResult decision;
PolicyResult result;
try
{
decision = await EvaluateCatalogPolicyAsync(context, catalogEntry);
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal);
}
catch (Exception ex)
{
@@ -92,125 +85,127 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return;
}
if (decision.IsAllowed)
if (result.Allowed)
{
// Store resolved context so functions can read IsGlobalAdmin/IsTenantAdmin without re-querying services
var principal = context.GetUser();
var jwtTenantId = principal?.GetTenantId() ?? string.Empty;
var isGlobalAdmin = decision.UserRole == "GlobalAdmin";
var targetTenantId = jwtTenantId; // default: JWT tenant
// Cross-tenant enforcement for routes with {tenantId} in the URL
if (catalogEntry.TenantScoping == TenantScoping.RouteParam)
{
var normalizedPath = requestPath.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)
? requestPath.Substring(5)
: requestPath;
var match = catalogEntry.RouteRegex.Match(normalizedPath);
var routeTenantId = match.Groups["tenantId"].Value;
if (!string.IsNullOrEmpty(routeTenantId))
{
targetTenantId = routeTenantId;
if (!isGlobalAdmin && !string.Equals(routeTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access: user={User} jwtTenant={JwtTenant} routeTenant={RouteTenant} path={Path}",
decision.UserIdentifier, jwtTenantId, routeTenantId, requestPath);
httpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new
{
error = "CrossTenantAccessDenied",
message = "Access denied. You can only access your own tenant's resources."
});
return;
}
}
}
// Cross-tenant enforcement for routes with optional ?tenantId= query parameter
if (catalogEntry.TenantScoping == TenantScoping.QueryParam)
{
var queryTenantId = httpContext.Request.Query["tenantId"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(queryTenantId))
{
targetTenantId = queryTenantId;
if (!isGlobalAdmin && !string.Equals(queryTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access (query): user={User} jwtTenant={JwtTenant} queryTenant={QueryTenant} path={Path}",
decision.UserIdentifier, jwtTenantId, queryTenantId, requestPath);
httpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new
{
error = "CrossTenantAccessDenied",
message = "Access denied. You can only access your own tenant's resources."
});
return;
}
}
}
context.Items[RequestContext.ItemsKey] = new RequestContext
{
TenantId = jwtTenantId,
TargetTenantId = targetTenantId,
UserPrincipalName = decision.UserIdentifier,
IsGlobalAdmin = isGlobalAdmin,
IsTenantAdmin = decision.UserRole == Constants.TenantRoles.Admin,
UserRole = decision.UserRole
};
_logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} policy={Policy} user={User} role={Role}",
httpMethod, requestPath, catalogEntry.Policy, decision.UserIdentifier, decision.UserRole);
context.Items[RequestContext.ItemsKey] = result.Context!;
_logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} user={User} role={Role}",
httpMethod, requestPath, result.UserIdentifier, result.UserRole);
await next(context);
return;
}
// Denied — determine 401 vs 403
var statusCode = decision.Reason is "NoJWT" or "MissingClaims"
? HttpStatusCode.Unauthorized
: HttpStatusCode.Forbidden;
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} policy={Policy} status={Status} user={User} role={Role} reason={Reason}",
httpMethod, requestPath, catalogEntry.Policy, (int)statusCode,
decision.UserIdentifier, decision.UserRole, decision.Reason);
httpContext.Response.StatusCode = (int)statusCode;
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason}",
httpMethod, requestPath, result.StatusCode, result.UserIdentifier, result.UserRole, result.LogReason);
httpContext.Response.StatusCode = result.StatusCode;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new { error = result.ErrorCode, message = result.ErrorMessage });
}
if (statusCode == HttpStatusCode.Unauthorized)
/// <summary>
/// Transport-agnostic authorization decision: catalog lookup → policy evaluation → cross-tenant
/// enforcement → RequestContext construction. Returns an allow (carrying the resolved RequestContext)
/// or a deny (carrying the HTTP status + error payload). Extracted from <see cref="Invoke"/> so the
/// full decision — including the additive tenant-role resolution that drives RequestContext.IsTenantAdmin —
/// is unit-testable without faking the worker's HttpContext feature. <see cref="Invoke"/> is a thin
/// adapter that reads the request, calls this, and writes the response.
/// </summary>
internal async Task<PolicyResult> DecideAsync(
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal)
{
var catalogEntry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath);
if (catalogEntry == null)
{
await httpContext.Response.WriteAsJsonAsync(new
{
error = "AuthenticationRequired",
message = "Authentication required. Please provide a valid JWT token."
});
// Fail-closed: unregistered route → 403
_logger.LogError("[PolicyEnforcement] BLOCKED unregistered route: {Method} {Path} — not in catalog (fail-closed)",
httpMethod, requestPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "Forbidden", "Access denied.", "anonymous", "N/A", "Unregistered");
}
else
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry);
if (!decision.IsAllowed)
{
await httpContext.Response.WriteAsJsonAsync(new
{
error = "InsufficientPermissions",
message = "Access denied. You do not have permission to access this resource."
});
var status = decision.Reason is "NoJWT" or "MissingClaims"
? HttpStatusCode.Unauthorized
: HttpStatusCode.Forbidden;
var (code, message) = status == HttpStatusCode.Unauthorized
? ("AuthenticationRequired", "Authentication required. Please provide a valid JWT token.")
: ("InsufficientPermissions", "Access denied. You do not have permission to access this resource.");
return PolicyResult.Deny((int)status, code, message, decision.UserIdentifier, decision.UserRole, decision.Reason);
}
var jwtTenantId = principal?.GetTenantId() ?? string.Empty;
var isGlobalAdmin = decision.UserRole == Constants.GlobalRoles.GlobalAdmin;
var isGlobalReader = decision.UserRole == Constants.GlobalRoles.GlobalReader;
// Platform-wide read scope (GA or Reader) governs cross-tenant visibility; write power is GA only.
var hasGlobalScope = isGlobalAdmin || isGlobalReader;
var targetTenantId = jwtTenantId; // default: JWT tenant
// Cross-tenant enforcement for routes with {tenantId} in the URL
if (catalogEntry.TenantScoping == TenantScoping.RouteParam)
{
var normalizedPath = requestPath.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)
? requestPath.Substring(5)
: requestPath;
var match = catalogEntry.RouteRegex.Match(normalizedPath);
var routeTenantId = match.Groups["tenantId"].Value;
if (!string.IsNullOrEmpty(routeTenantId))
{
targetTenantId = routeTenantId;
if (!hasGlobalScope && !string.Equals(routeTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access: user={User} jwtTenant={JwtTenant} routeTenant={RouteTenant} path={Path}",
decision.UserIdentifier, jwtTenantId, routeTenantId, requestPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied",
"Access denied. You can only access your own tenant's resources.",
decision.UserIdentifier, decision.UserRole, "CrossTenantRoute");
}
}
}
// Cross-tenant enforcement for routes with optional ?tenantId= query parameter
if (catalogEntry.TenantScoping == TenantScoping.QueryParam)
{
if (!string.IsNullOrWhiteSpace(queryTenantId))
{
targetTenantId = queryTenantId;
if (!hasGlobalScope && !string.Equals(queryTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access (query): user={User} jwtTenant={JwtTenant} queryTenant={QueryTenant} path={Path}",
decision.UserIdentifier, jwtTenantId, queryTenantId, requestPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied",
"Access denied. You can only access your own tenant's resources.",
decision.UserIdentifier, decision.UserRole, "CrossTenantQuery");
}
}
}
var requestContext = new RequestContext
{
TenantId = jwtTenantId,
TargetTenantId = targetTenantId,
UserPrincipalName = decision.UserIdentifier,
IsGlobalAdmin = isGlobalAdmin,
IsGlobalReader = isGlobalReader,
// Additive: a caller's tenant-admin status is its OWN-TENANT role, independent of any
// platform role. Evaluators that admit via a platform-role branch still surface the
// resolved tenant role on decision.TenantRole; pure-tenant branches leave it null and
// carry the role in UserRole — so fall back to UserRole when TenantRole is unset.
IsTenantAdmin = (decision.TenantRole ?? decision.UserRole) == Constants.TenantRoles.Admin,
UserRole = decision.UserRole
};
return PolicyResult.Allow(requestContext, decision.UserIdentifier, decision.UserRole);
}
private async Task<CatalogDecisionResult> EvaluateCatalogPolicyAsync(
FunctionContext context, EndpointPolicyEntry entry)
ClaimsPrincipal? principal, EndpointPolicyEntry entry)
{
// Get the ClaimsPrincipal set by AuthenticationMiddleware (may be null for anonymous routes)
ClaimsPrincipal? principal = null;
if (context.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal cp
&& cp.Identity?.IsAuthenticated == true)
{
principal = cp;
}
// principal is the authenticated ClaimsPrincipal (set by AuthenticationMiddleware), or null on
// anonymous/device routes — resolved by the caller (Invoke / DecideAsync).
var tenantId = principal?.GetTenantId();
var upn = principal?.GetUserPrincipalName();
var userIdentifier = upn ?? "anonymous";
@@ -234,12 +229,18 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
case EndpointPolicy.MemberRead:
return await EvaluateMemberReadAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrGlobalReader:
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.TenantAdminOrGA:
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.BootstrapManagerOrGA:
return await EvaluateBootstrapManagerOrGAAsync(tenantId, upn, principal, userIdentifier);
case EndpointPolicy.GlobalReadOrAdmin:
return await EvaluateGlobalReadOrAdminAsync(upn, userIdentifier);
case EndpointPolicy.GlobalAdminOnly:
return await EvaluateGlobalAdminOnlyAsync(upn, userIdentifier);
@@ -261,9 +262,22 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (principal == null)
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT");
// Global Admin outranks any tenant role.
if (!string.IsNullOrEmpty(upn) && await _globalAdminService.IsGlobalAdminAsync(upn))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "ValidJWT+GA");
// A platform role (GlobalAdmin or read-only GlobalReader) is surfaced as the primary role so
// per-resource gates inside the function can tell GA from Reader (e.g. cross-tenant SignalR
// group joins). ADDITIVE: we ALSO resolve the caller's own-tenant role and carry it separately,
// so a user who is both GlobalReader and TenantAdmin still has IsTenantAdmin=true in the context
// (admin notification group, admin-audience notifications).
if (!string.IsNullOrEmpty(upn))
{
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
if (globalRole != null)
{
var tenantRole = !string.IsNullOrEmpty(tenantId)
? (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role
: null;
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "ValidJWT+GlobalScope", tenantRole);
}
}
// Resolve the effective tenant role (may be null for non-members). A resolved role is
// surfaced so the function can distinguish members from roleless end users; either way
@@ -272,7 +286,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
{
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role != null)
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "ValidJWT+Member");
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "ValidJWT+Member", role.Role);
}
// Authenticated but not a member and not a GA — still allowed (the function decides which
@@ -286,15 +300,50 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(upn))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
// Any platform role (GlobalAdmin or read-only GlobalReader) satisfies a member-read. ADDITIVE:
// also resolve the own-tenant role and carry it separately so IsTenantAdmin stays correct for a
// GlobalReader who is also their tenant's admin (drives admin-audience on /api/notifications).
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
if (globalRole != null)
{
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole);
}
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role == null)
return CatalogDecisionResult.Deny(userIdentifier, "NonMember", "NotInTenant");
// MemberRead allows Admin, Operator, AND Viewer
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantMember");
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantMember", role.Role);
}
/// <summary>
/// Admin-tier tenant READ: own-tenant Admin, OR any platform role (GlobalAdmin / GlobalReader).
/// Used on GET routes that expose admin-tier tenant data (e.g. config) so the read-only GlobalReader
/// gains visibility without write power, while tenant Operators/Viewers remain excluded. ADDITIVE:
/// when admitted via the platform-role branch the caller's own-tenant role is ALSO resolved and
/// carried, so a GlobalReader who is also their tenant's Admin keeps IsTenantAdmin=true (config GET
/// then serves them the FULL config for their own tenant, redacting only the read-only-reader view).
/// </summary>
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrGlobalReaderAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
if (globalRole != null)
{
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole);
}
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
if (role?.Role == Constants.TenantRoles.Admin)
return CatalogDecisionResult.Allow(userIdentifier, Constants.TenantRoles.Admin, "TenantAdmin", Constants.TenantRoles.Admin);
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOrGlobalScope");
}
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrGAAsync(
@@ -357,6 +406,24 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return EntraAppRoleResolver.Resolve(state, tableRole, principal.GetAppRoles(), config.EntraAppRolesEnabled);
}
/// <summary>
/// Platform-wide cross-tenant READ: admits GlobalAdmin and the read-only GlobalReader. The
/// resolved role string flows onto RequestContext so the function still distinguishes GA from
/// Reader (e.g. for any in-function write gate).
/// </summary>
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrAdminAsync(
string? upn, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
return CatalogDecisionResult.Deny(userIdentifier, "NonGlobal", "NotGlobalScope");
}
private async Task<CatalogDecisionResult> EvaluateGlobalAdminOnlyAsync(
string? upn, string userIdentifier)
{
@@ -364,11 +431,44 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(upn))
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "IsGA");
return CatalogDecisionResult.Allow(userIdentifier, Constants.GlobalRoles.GlobalAdmin, "IsGA");
return CatalogDecisionResult.Deny(userIdentifier, "NonGA", "NotGlobalAdmin");
}
/// <summary>
/// Outcome of <see cref="DecideAsync"/>: either an allow carrying the resolved
/// <see cref="RequestContext"/>, or a deny carrying the HTTP status + error payload that
/// <see cref="Invoke"/> writes. Internal so the middleware decision is unit-testable.
/// </summary>
internal sealed class PolicyResult
{
public bool Allowed { get; private init; }
public RequestContext? Context { get; private init; }
public int StatusCode { get; private init; }
public string? ErrorCode { get; private init; }
public string? ErrorMessage { get; private init; }
public string UserIdentifier { get; private init; } = "anonymous";
public string UserRole { get; private init; } = "N/A";
public string LogReason { get; private init; } = "";
public static PolicyResult Allow(RequestContext context, string user, string role)
=> new() { Allowed = true, Context = context, UserIdentifier = user, UserRole = role, LogReason = "Allow" };
public static PolicyResult Deny(int statusCode, string errorCode, string errorMessage,
string user, string role, string logReason)
=> new()
{
Allowed = false,
StatusCode = statusCode,
ErrorCode = errorCode,
ErrorMessage = errorMessage,
UserIdentifier = user,
UserRole = role,
LogReason = logReason,
};
}
/// <summary>
/// Result of evaluating a catalog policy against the current request context.
/// </summary>
@@ -379,8 +479,17 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
public string UserRole { get; private init; } = "N/A";
public string Reason { get; private init; } = "";
public static CatalogDecisionResult Allow(string user, string role, string reason)
=> new() { IsAllowed = true, UserIdentifier = user, UserRole = role, Reason = reason };
/// <summary>
/// The caller's independently-resolved tenant role ("Admin"/"Operator"/"Viewer"), when known.
/// Carried SEPARATELY from <see cref="UserRole"/> so additive semantics survive: a caller admitted
/// via a platform-role branch (e.g. GlobalReader) still surfaces their own-tenant role here, so
/// RequestContext.IsTenantAdmin is correct for a user who is both GlobalReader and TenantAdmin.
/// Null when not resolved (the allow-path then falls back to <see cref="UserRole"/>).
/// </summary>
public string? TenantRole { get; private init; }
public static CatalogDecisionResult Allow(string user, string role, string reason, string? tenantRole = null)
=> new() { IsAllowed = true, UserIdentifier = user, UserRole = role, Reason = reason, TenantRole = tenantRole };
public static CatalogDecisionResult Deny(string user, string role, string reason)
=> new() { IsAllowed = false, UserIdentifier = user, UserRole = role, Reason = reason };
@@ -49,16 +49,36 @@ public enum EndpointPolicy
/// </summary>
AuthenticatedUserWithRole,
/// <summary>Tenant member with Admin, Operator, or Viewer role. Tenant-scoped read access.</summary>
/// <summary>
/// Tenant member with Admin, Operator, or Viewer role, OR any platform role (Global Admin /
/// Global Reader). Tenant-scoped read access.
/// </summary>
MemberRead,
/// <summary>
/// Own-tenant Admin, OR a platform role (Global Admin / Global Reader). Tenant-scoped admin-tier
/// READ access (e.g. GET config/{tenantId}). Like <see cref="TenantAdminOrGA"/> but additionally
/// admits the read-only Global Reader — used only on GET routes so the reader gets visibility
/// without write power, while tenant Operators/Viewers still cannot read admin-tier config.
/// </summary>
TenantAdminOrGlobalReader,
/// <summary>Tenant Admin or Global Admin. Tenant-scoped write access.</summary>
TenantAdminOrGA,
/// <summary>Admin (always) or Operator with CanManageBootstrapTokens permission, or Global Admin.</summary>
BootstrapManagerOrGA,
/// <summary>Global Admin only. Platform-wide access.</summary>
/// <summary>
/// Global Admin OR Global Reader. Platform-wide cross-tenant READ access (sessions, metrics, audit,
/// config read, reports, …). The read-only counterpart to <see cref="GlobalAdminOnly"/>: every
/// mutating/destructive global route stays GlobalAdminOnly. NOTE: raw table/log access
/// (global/raw/tables, global/raw/logs) is deliberately NOT on this tier — it stays GlobalAdminOnly
/// because it can dump secret-bearing tables, which would bypass the GlobalReader config redaction.
/// </summary>
GlobalReadOrAdmin,
/// <summary>Global Admin only. Platform-wide access (all writes + global settings).</summary>
GlobalAdminOnly,
}
@@ -133,9 +153,10 @@ public static class EndpointAccessPolicyCatalog
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser),
// Group join/leave is AuthenticatedUserWithRole — NOT MemberRead — on purpose. The real,
// per-group authorization lives inside SignalRAddToGroup/RemoveFromGroupFunction: it
// requires the requested group's tenant to match the caller's JWT tenant (Global Admins
// excepted), gates the global-admins group on IsGlobalAdmin, the admin-tier notification
// group on IsTenantAdmin, and the member-tier notification group on tenant membership.
// requires the requested group's tenant to match the caller's JWT tenant (platform-scope
// callers — GA or read-only Global Reader — excepted), gates the global-admins read-broadcast
// group on HasGlobalScope (GA ∪ GlobalReader), the admin-tier notification group on
// IsTenantAdmin (or GA), and the member-tier notification group on tenant membership (or scope).
// The Progress Portal is an END-USER feature: employees tracking their own device are
// authenticated (JWT tid = their tenant) but are typically NOT tenant members (no
// TenantAdmins row, no Entra app-role). Gating join at MemberRead let them load the
@@ -181,7 +202,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "metrics/vulnerability", EndpointPolicy.MemberRead),
new("GET", "metrics/software-inventory", EndpointPolicy.MemberRead),
new("GET", "metrics/geographic", EndpointPolicy.MemberRead),
new("GET", "metrics/mcp-usage/user/{userId}", EndpointPolicy.TenantAdminOrGA),
new("GET", "metrics/mcp-usage/user/{userId}", EndpointPolicy.TenantAdminOrGlobalReader),
new("GET", "metrics/geographic/sessions", EndpointPolicy.MemberRead),
new("GET", "metrics/ime-versions", EndpointPolicy.MemberRead),
new("GET", "audit/logs", EndpointPolicy.MemberRead),
@@ -191,7 +212,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "rules/gather", EndpointPolicy.MemberRead),
new("GET", "rules/analyze", EndpointPolicy.MemberRead),
new("GET", "rules/ime-log-patterns", EndpointPolicy.MemberRead),
new("GET", "config/{tenantId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("GET", "config/{tenantId}", EndpointPolicy.TenantAdminOrGlobalReader, TenantScoping.RouteParam),
new("GET", "config/{tenantId}/feature-flags", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
// ── TenantAdminOrGA ─────────────────────────────────────────────
@@ -234,7 +255,7 @@ public static class EndpointAccessPolicyCatalog
// POST (not GET) so payloads with 100+ script refs comfortably fit in the request
// body without hitting URL-length limits in browsers / Azure Functions front door.
new("POST", "tenants/{tenantId}/scripts/display-names", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
new("GET", "tenants/{tenantId}/graph-permissions/status", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("GET", "tenants/{tenantId}/graph-permissions/status", EndpointPolicy.TenantAdminOrGlobalReader, TenantScoping.RouteParam),
new("POST", "tenants/{tenantId}/graph-permissions/refresh", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
// ── BootstrapManagerOrGA ────────────────────────────────────────
@@ -248,83 +269,91 @@ public static class EndpointAccessPolicyCatalog
// ── MCP Usage (self-service) ──────────────────────────────────
new("GET", "metrics/mcp-usage/me", EndpointPolicy.AuthenticatedUser),
// ── GlobalAdminOnly ────────────────────────────────────────────
// Inspector v1 endpoints (Plan §M6 — temporarily GlobalAdminOnly while the UI
// matures; lift to MemberRead+QueryParam scoping at the v2 adminMode lift).
// Cross-tenant resolution for GAs happens inside the functions via SessionsIndex,
// so TenantScoping.None on the catalog is fine.
new("GET", "sessions/{sessionId}/signals", EndpointPolicy.GlobalAdminOnly),
new("GET", "sessions/{sessionId}/decision-graph", EndpointPolicy.GlobalAdminOnly),
new("GET", "sessions/{sessionId}/reducer-verification", EndpointPolicy.GlobalAdminOnly),
// ── GlobalReadOrAdmin + GlobalAdminOnly ─────────────────────────
// Cross-tenant routes. Curated READS (GET) are GlobalReadOrAdmin so the read-only Global Reader
// tier sees them; every mutating/destructive route stays GlobalAdminOnly. Exception in the OTHER
// direction: the raw table/log endpoints (global/raw/tables[/{tableName}], global/raw/logs) are
// reads but stay GlobalAdminOnly — they can dump secret-bearing tables and would bypass the
// GlobalReader config-secret redaction.
//
// Inspector v1 endpoints (Plan §M6 — lift to MemberRead+QueryParam scoping at the v2 adminMode
// lift). Cross-tenant resolution for global-scope callers happens inside the functions via
// SessionsIndex, so TenantScoping.None on the catalog is fine.
new("GET", "sessions/{sessionId}/signals", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "sessions/{sessionId}/decision-graph", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "sessions/{sessionId}/reducer-verification", EndpointPolicy.GlobalReadOrAdmin),
// Cascade-delete admin endpoints live under /api/global/* (not /api/admin/*) because the
// latter prefix collides with the Azure Functions runtime's own admin routes, which are
// mTLS-gated — browsers triggering preflight on /api/admin/* get TLS-renegotiation
// failures instead of CORS responses. Keep all cross-tenant GA-only HTTP routes under
// failures instead of CORS responses. Keep all cross-tenant global-scope HTTP routes under
// /api/global/.
new("GET", "global/sessions/{sessionId}/delete/preview", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/sessions/{sessionId}/delete/preview", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "global/sessions/{sessionId}/restore", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/sessions/{sessionId}/deletion-manifest", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/tenants/{tenantId}/deletion-manifests", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "global/session-deletions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/events", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/sessions/{sessionId}/deletion-manifest", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/tenants/{tenantId}/deletion-manifests", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam),
new("GET", "global/session-deletions", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/raw/sessions", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/raw/events", EndpointPolicy.GlobalReadOrAdmin),
// Raw table/log access stays GlobalAdminOnly: query_table can dump arbitrary tables
// (incl. TenantConfiguration secrets), which would bypass the GlobalReader config-secret
// redaction. A read-only GlobalReader uses the curated read endpoints instead.
new("GET", "global/raw/tables", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/tables/{tableName}", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/raw/logs", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/search/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/search/sessions-by-event", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/search/sessions-by-cve", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/summary", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/config", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/search/sessions", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/search/sessions-by-event", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/search/sessions-by-cve", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/summary", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/config", EndpointPolicy.GlobalReadOrAdmin),
new("PUT", "global/config", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/config", EndpointPolicy.GlobalAdminOnly),
new("GET", "config/all", EndpointPolicy.GlobalAdminOnly),
new("GET", "auth/global-admins", EndpointPolicy.GlobalAdminOnly),
new("GET", "config/all", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "auth/global-admins", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "auth/global-admins", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "auth/global-admins/{upn}", EndpointPolicy.GlobalAdminOnly),
new("GET", "preview/whitelist", EndpointPolicy.GlobalAdminOnly),
new("GET", "preview/whitelist", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "preview/whitelist/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("DELETE", "preview/whitelist/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "preview/notification-email/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "preview/notification-email/{tenantId}", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam),
new("POST", "preview/send-welcome-email/{tenantId}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "global/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/stats/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/audit/logs", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/tenants-with-deletion-manifests", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/platform", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/app", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/fleet-health", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/apps/list", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/apps/{appName}/analytics", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/apps/{appName}/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/geographic", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/geographic/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/usage", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/sla", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/distress-reports", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/session-reports", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/sessions", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/stats/sessions", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/audit/logs", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/tenants-with-deletion-manifests", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/platform", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/app", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/fleet-health", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/apps/list", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/apps/{appName}/analytics", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/apps/{appName}/sessions", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/geographic", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/geographic/sessions", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/usage", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/sla", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalReadOrAdmin),
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/rules/gather", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/rules/analyze", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/devices/blocked", EndpointPolicy.GlobalAdminOnly),
new("GET", "devices/blocked", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/rules/analyze", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/devices/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "devices/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "devices/block", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "devices/block/{encodedSerialNumber}", EndpointPolicy.GlobalAdminOnly),
new("GET", "versions/blocked", EndpointPolicy.GlobalAdminOnly),
new("GET", "versions/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "versions/block", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "versions/block/{encodedPattern}", EndpointPolicy.GlobalAdminOnly),
new("POST", "maintenance/trigger", EndpointPolicy.GlobalAdminOnly),
// ── Critical-Table Backup (plan §PR1+§PR2) ─────────────────────────────
new("POST", "global/backups/trigger", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/backups", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/backups/{backupId}", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/backups/jobs/{jobId}", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/backups", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/backups/{backupId}", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/backups/jobs/{jobId}", EndpointPolicy.GlobalReadOrAdmin),
// PR2: single-row preview + commit. PartitionKey + RowKey live in the body
// (Azure Tables permits / + % in PK/RK; the route wouldn't carry them safely).
new("POST", "global/backups/{backupId}/restore-row", EndpointPolicy.GlobalAdminOnly),
@@ -335,38 +364,38 @@ public static class EndpointAccessPolicyCatalog
// The {tenantId} route param is the OFFBOARDED tenant whose data was archived;
// the operator (GA) does not belong to that tenant. RouteParam scoping declares
// that the route's tenantId is supplied from the URL, not from the caller's JWT.
new("GET", "global/customs-archive", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/customs-archive/{tenantId}/{historyRowKey}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "global/customs-archive", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/customs-archive/{tenantId}/{historyRowKey}", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam),
new("GET", "global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey}", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.RouteParam),
new("DELETE", "global/customs-archive/{tenantId}/{historyRowKey}/{archiveRowKey}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("DELETE", "global/customs-archive/{tenantId}/{historyRowKey}", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "health/detailed", EndpointPolicy.AuthenticatedUser),
new("GET", "health/mcp", EndpointPolicy.AuthenticatedUser),
new("POST", "rules/reseed-from-github", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/unmatched-software", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/software-inventory", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/unmatched-software", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "vulnerability/software-inventory", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "vulnerability/sync", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/sync-msrc", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/sync-status", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/sync-status", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
new("POST", "vulnerability/cpe-mapping/auto-resolve", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/cpe-mappings", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
new("GET", "vulnerability/cpe-mappings", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "vulnerability/ignored-software", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/mcp-users", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/mcp-users", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "global/mcp-users", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "global/mcp-users/{upn}", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "global/mcp-users/{upn}/enable", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "global/mcp-users/{upn}/disable", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "global/mcp-users/{upn}/usage-plan", EndpointPolicy.GlobalAdminOnly),
new("PATCH", "config/{tenantId}/plan", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
new("GET", "global/config/plan-tiers", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/config/plan-tiers", EndpointPolicy.GlobalReadOrAdmin),
new("PUT", "global/config/plan-tiers", EndpointPolicy.GlobalAdminOnly),
new("GET", "feedback/all", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/notifications", EndpointPolicy.GlobalAdminOnly),
new("GET", "feedback/all", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/notifications", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "global/notifications/dismiss-all", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/notifications/{notificationId}/dismiss", EndpointPolicy.GlobalAdminOnly),
// Tenant-scoped persistent notifications (bell). TenantId comes from JWT — middleware enforces.
@@ -378,7 +407,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "notifications", EndpointPolicy.MemberRead, TenantScoping.Jwt),
new("POST", "notifications/dismiss-all", EndpointPolicy.TenantAdminOrGA, TenantScoping.Jwt),
new("POST", "notifications/{notificationId}/dismiss", EndpointPolicy.TenantAdminOrGA, TenantScoping.Jwt),
new("GET", "global/ops-events", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/ops-events", EndpointPolicy.GlobalReadOrAdmin),
};
/// <summary>
@@ -1,3 +1,4 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using Azure.Data.Tables;
using Microsoft.Extensions.Caching.Memory;
@@ -16,6 +17,10 @@ public class GlobalAdminService
private readonly ILogger<GlobalAdminService> _logger;
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);
// Sentinel stored in the role cache to represent "no global role" (row missing or disabled).
// Lets us distinguish a cached negative from a cache miss without nullable boxing games.
private const string NoRoleSentinel = "(none)";
public GlobalAdminService(
IAdminRepository adminRepo,
IMemoryCache cache,
@@ -33,34 +38,42 @@ public class GlobalAdminService
/// <param name="upn">User Principal Name (e.g., oliver@contoso.com)</param>
/// <returns>True if the user is a Global Admin</returns>
public virtual async Task<bool> IsGlobalAdminAsync(string? upn)
{
// Single source of truth: GlobalAdmin == the GlobalAdmin platform role.
return await GetGlobalRoleAsync(upn) == Constants.GlobalRoles.GlobalAdmin;
}
/// <summary>
/// Resolves the caller's platform role from the GlobalAdmins table.
/// Returns <see cref="Constants.GlobalRoles.GlobalAdmin"/>, <see cref="Constants.GlobalRoles.GlobalReader"/>,
/// or <c>null</c> when the UPN has no enabled GlobalAdmins row. Cached for 5 minutes.
/// </summary>
public virtual async Task<string?> GetGlobalRoleAsync(string? upn)
{
if (string.IsNullOrWhiteSpace(upn))
{
_logger.LogDebug("IsGlobalAdminAsync: UPN is null or empty");
return false;
_logger.LogDebug("GetGlobalRoleAsync: UPN is null or empty");
return null;
}
// Normalize UPN to lowercase for case-insensitive comparison
upn = upn.ToLowerInvariant();
// Check cache first
var cacheKey = $"global-admin:{upn}";
if (_cache.TryGetValue<bool>(cacheKey, out var isAdmin))
var cacheKey = $"global-role:{upn}";
if (_cache.TryGetValue<string>(cacheKey, out var cached) && cached != null)
{
_logger.LogDebug("Global Admin check (from cache): {Upn} -> {IsAdmin}", upn, isAdmin);
return isAdmin;
_logger.LogDebug("Global role check (from cache): {Upn} -> {Role}", upn, cached);
return cached == NoRoleSentinel ? null : cached;
}
// Query via repository
_logger.LogDebug("Querying repository for Global Admin: {Upn}", upn);
var result = await _adminRepo.IsGlobalAdminAsync(upn);
_logger.LogDebug("Querying repository for global role: {Upn}", upn);
var role = await _adminRepo.GetGlobalRoleAsync(upn);
_logger.LogDebug("Global Admin check result: {Upn} -> {Result}", upn, result);
_logger.LogDebug("Global role check result: {Upn} -> {Role}", upn, role ?? "(none)");
// Cache the result
_cache.Set(cacheKey, result, _cacheDuration);
_cache.Set(cacheKey, role ?? NoRoleSentinel, _cacheDuration);
return result;
return role;
}
/// <summary>
@@ -76,7 +89,7 @@ public class GlobalAdminService
await _adminRepo.AddGlobalAdminAsync(upn, addedBy);
// Invalidate cache
_cache.Remove($"global-admin:{upn}");
_cache.Remove($"global-role:{upn}");
return new GlobalAdminEntity
{
@@ -85,7 +98,8 @@ public class GlobalAdminService
Upn = upn,
IsEnabled = true,
AddedDate = DateTime.UtcNow,
AddedBy = addedBy
AddedBy = addedBy,
Role = Constants.GlobalRoles.GlobalAdmin
};
}
@@ -99,7 +113,7 @@ public class GlobalAdminService
await _adminRepo.RemoveGlobalAdminAsync(upn);
// Invalidate cache
_cache.Remove($"global-admin:{upn}");
_cache.Remove($"global-role:{upn}");
}
/// <summary>
@@ -112,7 +126,7 @@ public class GlobalAdminService
await _adminRepo.DisableGlobalAdminAsync(upn);
// Invalidate cache
_cache.Remove($"global-admin:{upn}");
_cache.Remove($"global-role:{upn}");
}
/// <summary>
@@ -129,7 +143,8 @@ public class GlobalAdminService
Upn = e.Upn,
IsEnabled = e.IsEnabled,
AddedDate = e.AddedAt,
AddedBy = e.AddedBy
AddedBy = e.AddedBy,
Role = string.IsNullOrEmpty(e.Role) ? Constants.GlobalRoles.GlobalAdmin : e.Role
}).ToList();
}
@@ -174,4 +189,11 @@ public class GlobalAdminEntity : ITableEntity
/// UPN of the admin who added this user
/// </summary>
public string AddedBy { get; set; } = string.Empty;
/// <summary>
/// Platform role for this entry: <see cref="Constants.GlobalRoles.GlobalAdmin"/> (default) or
/// <see cref="Constants.GlobalRoles.GlobalReader"/>. Empty/missing ⇒ GlobalAdmin (back-compat with
/// rows created before the GlobalReader tier existed).
/// </summary>
public string Role { get; set; } = string.Empty;
}
@@ -1,3 +1,4 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Data.Tables;
@@ -46,10 +47,13 @@ public class McpUserService
upn = upn.ToLowerInvariant();
// Always resolve GA status — needed by the MCP server for cross-tenant routing
// decisions (GA → /api/global/* with tenantId-as-filter; non-GA → /api/* JWT-bound).
// Resolved unconditionally so downstream consumers don't need to re-check.
var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn);
// Always resolve the platform role — needed by the MCP server for cross-tenant routing
// decisions (global scope → /api/global/* with tenantId-as-filter; non-global → /api/* JWT-bound).
// GlobalReader has the SAME cross-tenant read scope as GA in the (read-only) MCP, so both
// platform roles route the same way; only isGlobalAdmin (write power) differs. Resolved
// unconditionally so downstream consumers don't need to re-check.
var globalRole = await _globalAdminService.GetGlobalRoleAsync(upn);
var isGlobalAdmin = globalRole == Constants.GlobalRoles.GlobalAdmin;
var config = await _adminConfigService.GetConfigurationAsync();
if (!Enum.TryParse<McpAccessPolicy>(config.McpAccessPolicy, out var policy))
@@ -61,13 +65,13 @@ public class McpUserService
return McpAccessCheckResult.Denied("MCP access is disabled");
case McpAccessPolicy.AllMembers:
return McpAccessCheckResult.Allowed(upn, "AllMembers", isGlobalAdmin);
return McpAccessCheckResult.Allowed(upn, "AllMembers", isGlobalAdmin, globalRole);
case McpAccessPolicy.WhitelistOnly:
default:
// Global Admins always have access
if (isGlobalAdmin)
return McpAccessCheckResult.Allowed(upn, "GlobalAdmin", true);
// Any platform role (GlobalAdmin or read-only GlobalReader) always has MCP access.
if (globalRole != null)
return McpAccessCheckResult.Allowed(upn, globalRole, isGlobalAdmin, globalRole);
// Check McpUsers whitelist (cached)
var cacheKey = $"mcp-user:{upn}";
@@ -160,12 +164,19 @@ public class McpAccessCheckResult
public string AccessGrant { get; init; } = string.Empty;
public bool IsGlobalAdmin { get; init; }
public static McpAccessCheckResult Allowed(string upn, string accessGrant, bool isGlobalAdmin = false) => new()
/// <summary>
/// Platform role of the caller: "GlobalAdmin", "GlobalReader", or null (no platform role). The MCP
/// server uses this to grant cross-tenant routing to BOTH platform roles (the server is read-only).
/// </summary>
public string? GlobalRole { get; init; }
public static McpAccessCheckResult Allowed(string upn, string accessGrant, bool isGlobalAdmin = false, string? globalRole = null) => new()
{
IsAllowed = true,
Upn = upn,
AccessGrant = accessGrant,
IsGlobalAdmin = isGlobalAdmin
IsGlobalAdmin = isGlobalAdmin,
GlobalRole = globalRole
};
public static McpAccessCheckResult Denied(string reason) => new()
@@ -22,7 +22,7 @@ process.env.MCP_RATE_LIMIT_PER_MINUTE = '2';
const { accessGuard } = await import('../access-guard.js');
// Same ESM singleton the guard uses internally — so the context set by
// runWithCaller inside next() is observable here.
const { isGlobalAdmin, getCurrentToken } = await import('../client.js');
const { isGlobalAdmin, hasGlobalScope, getCurrentToken } = await import('../client.js');
/** Build an unsigned JWT-shaped token carrying the given claims. */
function makeToken(claims: Record<string, unknown>): string {
@@ -106,7 +106,7 @@ function runGuard(req: Request): Promise<GuardOutcome> {
// Read the per-request caller context from inside the async scope.
done({
nextCalled: true,
ctx: { token: getCurrentToken(), ga: isGlobalAdmin() },
ctx: { token: getCurrentToken(), ga: isGlobalAdmin(), scope: hasGlobalScope() },
status: cap.statusCode,
body: cap.body,
headers: cap.headers,
@@ -225,7 +225,7 @@ describe('accessGuard — allow path', () => {
expect(out.nextCalled).toBe(true);
expect(out.status).toBeNull(); // no response sent on the allow path
expect(out.ctx).toEqual({ token, ga: true });
expect(out.ctx).toEqual({ token, ga: true, scope: true });
});
it('propagates isGlobalAdmin:false through the caller context', async () => {
@@ -233,6 +233,23 @@ describe('accessGuard — allow path', () => {
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.nextCalled).toBe(true);
expect(out.ctx?.ga).toBe(false);
expect(out.ctx?.scope).toBe(false);
});
it('maps globalRole=GlobalReader to global scope WITHOUT global-admin (write) status', async () => {
stubBackend({ body: { allowed: true, accessGrant: 'GlobalReader', globalRole: 'GlobalReader' } });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.nextCalled).toBe(true);
expect(out.ctx?.ga).toBe(false); // not a Global Admin
expect(out.ctx?.scope).toBe(true); // but has cross-tenant read scope
});
it('maps globalRole=GlobalAdmin to both global scope and global-admin status', async () => {
stubBackend({ body: { allowed: true, accessGrant: 'GlobalAdmin', globalRole: 'GlobalAdmin' } });
const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`));
expect(out.nextCalled).toBe(true);
expect(out.ctx?.ga).toBe(true);
expect(out.ctx?.scope).toBe(true);
});
});
@@ -14,6 +14,7 @@ import { describe, it, expect } from 'vitest';
import {
pickGlobalOrTenantPath,
isGlobalAdmin,
hasGlobalScope,
getCurrentToken,
runWithCaller,
} from '../client.js';
@@ -44,6 +45,14 @@ describe('pickGlobalOrTenantPath — GA gating', () => {
});
});
it('routes a read-only Global Reader to /api/global/* (scope, not GA write status)', () => {
runWithCaller({ token: 'reader-token', isGlobalAdmin: false, isGlobalReader: true }, () => {
expect(isGlobalAdmin()).toBe(false); // not a write-tier Global Admin
expect(hasGlobalScope()).toBe(true); // but has cross-tenant read scope
expect(pickGlobalOrTenantPath(GLOBAL, TENANT)).toBe(GLOBAL);
});
});
it('reverts to the no-context default after the callback returns', () => {
runWithCaller({ token: 't', isGlobalAdmin: true }, () => {
expect(isGlobalAdmin()).toBe(true);
@@ -11,11 +11,11 @@ import { describe, it, expect } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerTools } from '../tools.js';
function registeredToolNames(ga: boolean): string[] {
function registeredToolNames(ga: boolean, strictGa: boolean = ga): string[] {
const server = new McpServer({ name: 'test', version: '0.0.0' });
// knowledgeBase / eventTypeIndex are optional — pass undefined so this stays a
// pure unit test with no search-provider or backend dependency.
registerTools(server, undefined, undefined, ga);
registerTools(server, undefined, undefined, ga, strictGa);
const internal = server as unknown as { _registeredTools: Record<string, unknown> };
return Object.keys(internal._registeredTools);
}
@@ -54,4 +54,19 @@ describe('tool catalog ordering', () => {
expect(gaNames).toContain('list_tenants');
expect(tenantNames).not.toContain('list_tenants');
});
it('gives a Global Reader the platform read tools but NOT the secret-bearing raw tools', () => {
// A Global Reader has platform scope (ga=true) but is not a real Global Admin (strictGa=false).
const readerNames = registeredToolNames(true, false);
const gaNames = registeredToolNames(true, true);
// Curated cross-tenant read tools are present for the reader…
expect(readerNames).toContain('list_tenants');
expect(readerNames).toContain('get_api_usage');
// …but the raw secret-bearing tools are GA-strict only.
for (const raw of ['query_table', 'list_tables', 'query_backend_logs']) {
expect(gaNames).toContain(raw);
expect(readerNames).not.toContain(raw);
}
});
});
@@ -33,6 +33,7 @@ interface AccessCacheEntry {
allowed: boolean;
reason: string;
isGlobalAdmin: boolean;
isGlobalReader: boolean;
expiresAt: number;
}
@@ -48,6 +49,7 @@ interface AccessCheckResult {
allowed: boolean;
reason: string;
isGlobalAdmin: boolean;
isGlobalReader: boolean;
}
export function buildCacheKey(upn: string, token: string): string {
@@ -59,7 +61,12 @@ async function checkAccess(upn: string, token: string): Promise<AccessCheckResul
const cacheKey = buildCacheKey(upn, token);
const cached = accessCache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
return { allowed: cached.allowed, reason: cached.reason, isGlobalAdmin: cached.isGlobalAdmin };
return {
allowed: cached.allowed,
reason: cached.reason,
isGlobalAdmin: cached.isGlobalAdmin,
isGlobalReader: cached.isGlobalReader,
};
}
try {
@@ -70,7 +77,7 @@ async function checkAccess(upn: string, token: string): Promise<AccessCheckResul
const text = await res.text();
if (!text) {
console.error(`[access-guard] Backend returned empty body for ${upn} (status=${res.status})`);
return { allowed: false, reason: `Backend returned ${res.status} with empty body`, isGlobalAdmin: false };
return { allowed: false, reason: `Backend returned ${res.status} with empty body`, isGlobalAdmin: false, isGlobalReader: false };
}
const data = JSON.parse(text) as {
@@ -78,11 +85,15 @@ async function checkAccess(upn: string, token: string): Promise<AccessCheckResul
reason?: string;
accessGrant?: string;
isGlobalAdmin?: boolean;
// Platform role: "GlobalAdmin" | "GlobalReader" (absent → no platform role). The read-only
// GlobalReader gets the same cross-tenant routing as GA because this server is read-only.
globalRole?: string;
};
const result: AccessCheckResult = {
allowed: data.allowed === true,
reason: data.allowed ? (data.accessGrant ?? 'allowed') : (data.reason ?? 'denied'),
isGlobalAdmin: data.isGlobalAdmin === true,
isGlobalAdmin: data.isGlobalAdmin === true || data.globalRole === 'GlobalAdmin',
isGlobalReader: data.globalRole === 'GlobalReader',
};
accessCache.set(cacheKey, { ...result, expiresAt: Date.now() + ACCESS_CACHE_TTL_MS });
@@ -90,7 +101,7 @@ async function checkAccess(upn: string, token: string): Promise<AccessCheckResul
} catch (err) {
console.error(`[access-guard] Backend check failed for ${upn}:`, err);
// Fail-closed: deny on backend error
return { allowed: false, reason: 'Backend access check unavailable', isGlobalAdmin: false };
return { allowed: false, reason: 'Backend access check unavailable', isGlobalAdmin: false, isGlobalReader: false };
}
}
@@ -194,10 +205,13 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
return;
}
// Scope the caller context (token + GA status) to this async context
// Scope the caller context (token + platform role) to this async context
// so concurrent sessions cannot overwrite each other on the event loop,
// and so tools can route based on role without re-checking the JWT.
runWithCaller({ token, isGlobalAdmin: result.isGlobalAdmin }, () => next());
runWithCaller(
{ token, isGlobalAdmin: result.isGlobalAdmin, isGlobalReader: result.isGlobalReader },
() => next(),
);
})
.catch(() => {
res.status(503).json({ error: 'Access check failed' });
@@ -33,17 +33,22 @@ export class ApiError extends Error {
* Per-request caller context using AsyncLocalStorage.
*
* Each incoming MCP request runs inside its own async context (via
* `runWithCaller`), carrying both the Bearer token and the caller's
* resolved Global-Admin status. Tools route based on the role:
* - GA → /api/global/* (cross-tenant; tenantId is a filter param)
* - Tenant-Admin → /api/* (tenant-scoped; JWT-tid is authoritative)
* `runWithCaller`), carrying the Bearer token and the caller's resolved
* platform role. Tools route on platform SCOPE (Global Admin OR the read-only
* Global Reader — the server is read-only, so both have identical cross-tenant
* reach):
* - global scope → /api/global/* (cross-tenant; tenantId is a filter param)
* - tenant user → /api/* (tenant-scoped; JWT-tid is authoritative)
*
* Concurrent sessions cannot overwrite each other's context even when
* async operations interleave on the event loop.
*/
interface CallerContext {
token: string;
/** True only for a platform Global Admin (write tier — not currently used by the read-only MCP). */
isGlobalAdmin: boolean;
/** True for the read-only Global Reader platform tier. */
isGlobalReader?: boolean;
}
const callerStore = new AsyncLocalStorage<CallerContext>();
@@ -71,13 +76,26 @@ export function isGlobalAdmin(): boolean {
}
/**
* Picks the route prefix for the current caller. GA always uses
* `/api/global/*`; tenant-admins use the tenant-scoped variant. The same
* `tenantId` query param is meaningful in both worlds — on `/api/global/*`
* it filters; on `/api/*` it's informational (backend resolves from JWT).
* True when the current caller has platform-wide (cross-tenant) scope — a Global
* Admin OR a read-only Global Reader. This is what tool routing and tool-catalog
* gating key off, because the MCP server is entirely read-only: a Global Reader
* has the same cross-tenant reach as a Global Admin here. Defaults to false when
* no context is active (e.g. unit tests without an explicit `runWithCaller`).
*/
export function hasGlobalScope(): boolean {
const store = callerStore.getStore();
return store?.isGlobalAdmin === true || store?.isGlobalReader === true;
}
/**
* Picks the route prefix for the current caller. A platform-scope caller (GA or
* read-only Global Reader) uses `/api/global/*`; tenant users use the
* tenant-scoped variant. The same `tenantId` query param is meaningful in both
* worlds — on `/api/global/*` it filters; on `/api/*` it's informational
* (backend resolves from JWT).
*/
export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string): string {
return isGlobalAdmin() ? globalPath : tenantPath;
return hasGlobalScope() ? globalPath : tenantPath;
}
/**
@@ -15,7 +15,7 @@ import { validatePrecomputedIndex } from './precomputed-index.js';
import { buildEventTypeSearchDocs } from './resource-catalog.js';
import { createOAuthRouter } from './oauth.js';
import { accessGuard } from './access-guard.js';
import { isGlobalAdmin } from './client.js';
import { hasGlobalScope, isGlobalAdmin } from './client.js';
import { API_BASE_URL } from './config.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -133,9 +133,10 @@ console.error(`Event-type index ready: ${eventTypeIndex.name} — ${eventTypeInd
// into every tool description (and re-sent on every tools/list). Keep it short:
// it is always-on context, not a manual.
//
// Role-aware: only a Global Admin sees the cross-tenant scope hint. A normal
// tenant user gets instructions with no mention of Global-Admin / cross-tenant
// capability at all — the surface is scoped to what they can actually do.
// Role-aware: only a platform-scope caller (Global Admin or read-only Global
// Reader) sees the cross-tenant scope hint. A normal tenant user gets
// instructions with no mention of cross-tenant capability at all — the surface
// is scoped to what they can actually do.
function buildInstructions(ga: boolean): string {
return [
'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.',
@@ -146,7 +147,7 @@ function buildInstructions(ga: boolean): string {
'Pagination: when a response carries `nextLink`, pass that whole string back as `continuation`; stop when it is absent. Results are never silently truncated.',
'Catalogs: call get_resource(name="event_types"|"device_properties") to discover valid eventType strings and deviceProperties keys before filtering.',
ga
? 'Scope: omit tenantId for cross-tenant queries (Global Admin only); pass tenantId to scope to one tenant.'
? 'Scope: omit tenantId for cross-tenant queries (platform scope); pass tenantId to scope to one tenant.'
: 'Scope: all queries are automatically limited to your tenant.',
].join('\n');
}
@@ -157,12 +158,12 @@ function buildInstructions(ga: boolean): string {
* role: a non-Global-Admin never sees GA-only tools or any cross-tenant / GA
* wording — reducing both confusion and attack surface.
*/
function createMcpServer(ga: boolean): McpServer {
function createMcpServer(ga: boolean, strictGa: boolean): McpServer {
const s = new McpServer(
{ name: 'Autopilot-Monitor', version: SERVER_VERSION },
{ instructions: buildInstructions(ga) },
);
registerTools(s, knowledgeBase, eventTypeIndex, ga);
registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa);
registerResources(s);
registerPrompts(s, ga);
return s;
@@ -233,10 +234,12 @@ app.all('/mcp', async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless: no session tracking
});
// accessGuard ran runWithCaller({ isGlobalAdmin }) around next(), so the
// caller's resolved role is available here (and stays active through
// transport.handleRequest, where tools/list and tool calls execute).
const server = createMcpServer(isGlobalAdmin());
// accessGuard ran runWithCaller({ platform role }) around next(), so the
// caller's resolved scope is available here (and stays active through
// transport.handleRequest, where tools/list and tool calls execute). Tool
// catalog + routing key off platform SCOPE (GA or read-only Global Reader),
// since the server is read-only and both have identical cross-tenant reach.
const server = createMcpServer(hasGlobalScope(), isGlobalAdmin());
// Guarantee cleanup once the response is done, even on client disconnect.
res.on('close', () => {
@@ -6,19 +6,24 @@ import { registerAdminTools } from './tools/admin.js';
/**
* Registers the tool catalog for a single request, tailored to the caller's role.
* When `ga` is false (normal tenant user), Global-Admin-only tools are not
* registered at all — they never appear in tools/list — and the remaining tools'
* descriptions carry no cross-tenant / Global-Admin wording.
*
* `ga` here means platform SCOPE (Global Admin OR read-only Global Reader) — the broad gate for
* cross-tenant read tools. `strictGa` is true ONLY for a real Global Admin and gates the few raw
* tools whose backend endpoints stay GlobalAdminOnly (list_tables / query_table / query_backend_logs)
* because they can dump secret-bearing tables that the GlobalReader config redaction would otherwise
* hide. When `ga` is false (normal tenant user) no platform tool is registered at all — they never
* appear in tools/list — and the remaining tools' descriptions carry no cross-tenant wording.
*/
export function registerTools(
server: McpServer,
knowledgeBase: SearchProvider | undefined,
eventTypeIndex: SearchProvider | undefined,
ga: boolean,
strictGa: boolean = ga,
): void {
registerSessionTools(server, ga);
registerSearchTools(server, knowledgeBase, eventTypeIndex, ga);
registerAdminTools(server, ga);
registerAdminTools(server, ga, strictGa);
sortToolCatalog(server);
}
@@ -166,7 +166,7 @@ export function paginateInventory(
};
}
export function registerAdminTools(server: McpServer, ga: boolean): void {
export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boolean = ga): void {
// Tool 11: get_api_usage — Global Admin only; not registered for normal users
// (the `if (ga)` guards the whole single server.registerTool(...) statement).
if (ga) server.registerTool(
@@ -762,12 +762,13 @@ export function registerAdminTools(server: McpServer, ga: boolean): void {
// ── Admin Diagnostic Tools ────────────────────────────────────────────
// Security note: query_table and query_backend_logs accept arbitrary OData/KQL
// expressions. This is by design — these are trusted-admin-only diagnostic tools
// gated by GlobalAdmin RBAC on the backend. No client-side filter allowlist is
// needed because the backend enforces the same permission boundary.
// expressions AND can read secret-bearing tables (e.g. TenantConfiguration). They are
// therefore gated on `strictGa` (real Global Admin only), NOT the broader platform scope —
// a read-only GlobalReader must not bypass the config-secret redaction via raw table access.
// The backend endpoints (global/raw/tables, global/raw/logs) are GlobalAdminOnly to match.
// Tool 20: list_tables — Global Admin only; not registered for normal users.
if (ga) server.registerTool(
if (strictGa) server.registerTool(
'list_tables',
{
title: 'List Tables',
@@ -786,7 +787,7 @@ export function registerAdminTools(server: McpServer, ga: boolean): void {
);
// Tool 21: query_table — Global Admin only; not registered for normal users.
if (ga) server.registerTool(
if (strictGa) server.registerTool(
'query_table',
{
title: 'Query Table',
@@ -849,7 +850,7 @@ export function registerAdminTools(server: McpServer, ga: boolean): void {
);
// Tool 22: query_backend_logs — Global Admin only; not registered for normal users.
if (ga) server.registerTool(
if (strictGa) server.registerTool(
'query_backend_logs',
{
title: 'Query Backend Logs',
@@ -640,6 +640,26 @@ namespace AutopilotMonitor.Shared
public const string Viewer = "Viewer";
}
/// <summary>
/// Platform-wide roles stored in the GlobalAdmins table (Role column).
/// Empty/missing Role on existing entities is treated as GlobalAdmin for backward compatibility.
/// GlobalReader = read-only platform tier: cross-tenant read of telemetry + config (config secrets
/// redacted) but performs NO mutating operation and cannot write global settings. Raw table/log
/// access stays GlobalAdminOnly (it could dump secret-bearing tables, bypassing redaction).
/// </summary>
public static class GlobalRoles
{
public const string GlobalAdmin = "GlobalAdmin";
public const string GlobalReader = "GlobalReader";
}
/// <summary>
/// Placeholder substituted for secret-bearing string fields (SAS URLs, webhook URLs, API keys,
/// custom webhook headers) when a config object is served to a read-only GlobalReader. Non-empty
/// secrets become this sentinel so the UI shows "configured but hidden"; empty stays empty.
/// </summary>
public const string RedactedSecretPlaceholder = "***REDACTED***";
// -----------------------------------------------------------------------
// Audit logging
// -----------------------------------------------------------------------
@@ -12,6 +12,11 @@ namespace AutopilotMonitor.Shared.DataAccess
{
// --- Global Admins ---
Task<bool> IsGlobalAdminAsync(string upn);
/// <summary>
/// Resolves the platform role for a UPN: "GlobalAdmin", "GlobalReader", or null when there is no
/// enabled GlobalAdmins row. Empty/missing Role on an existing enabled row ⇒ "GlobalAdmin" (back-compat).
/// </summary>
Task<string?> GetGlobalRoleAsync(string upn);
Task<List<GlobalAdminEntry>> GetAllGlobalAdminsAsync();
Task<bool> AddGlobalAdminAsync(string upn, string addedBy);
Task<bool> RemoveGlobalAdminAsync(string upn);
@@ -43,6 +48,8 @@ namespace AutopilotMonitor.Shared.DataAccess
public bool IsEnabled { get; set; } = true;
public DateTime AddedAt { get; set; }
public string AddedBy { get; set; } = string.Empty;
/// <summary>Platform role: "GlobalAdmin" (default) or "GlobalReader". Empty ⇒ GlobalAdmin (back-compat).</summary>
public string Role { get; set; } = string.Empty;
}
public class McpUserEntry
@@ -459,6 +459,29 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string MsrcLastSyncUtc { get; set; } = default!;
/// <summary>
/// Returns a shallow copy with all secret-bearing string fields replaced by
/// <see cref="Constants.RedactedSecretPlaceholder"/> (empty values are left empty). Used when
/// serving the global config to a read-only GlobalReader so SAS URLs / API keys / webhook URLs
/// (which could enable external mutations) never leave the backend. The original (cached) instance
/// is never mutated.
///
/// SECURITY: this is a deny-list — every new secret string field MUST be added here.
/// <c>AdminConfigurationRedactionTests</c> guards against drift.
/// </summary>
public AdminConfiguration RedactedCopyForReader()
{
var copy = (AdminConfiguration)MemberwiseClone();
copy.PlatformStatsBlobSasUrl = Redact(copy.PlatformStatsBlobSasUrl);
copy.NvdApiKey = Redact(copy.NvdApiKey);
copy.OpsAlertTeamsWebhookUrl = Redact(copy.OpsAlertTeamsWebhookUrl);
copy.OpsAlertSlackWebhookUrl = Redact(copy.OpsAlertSlackWebhookUrl);
return copy;
static string Redact(string? value)
=> string.IsNullOrEmpty(value) ? (value ?? string.Empty) : Constants.RedactedSecretPlaceholder;
}
/// <summary>
/// Creates default configuration
/// </summary>
@@ -614,6 +614,44 @@ namespace AutopilotMonitor.Shared.Models
// ===== HELPER METHODS =====
/// <summary>
/// Returns a shallow copy with all secret-bearing string fields replaced by
/// <see cref="Constants.RedactedSecretPlaceholder"/> (empty values are left empty). Used when
/// serving a tenant config to a read-only GlobalReader so SAS URLs / webhook URLs / custom
/// webhook headers (which could enable external mutations) never leave the backend. The original
/// (cached) instance is never mutated.
///
/// SECURITY: this is a deny-list — every new secret string field MUST be added here.
/// <c>TenantConfigurationRedactionTests</c> guards against drift.
/// </summary>
public TenantConfiguration RedactedCopyForReader()
{
var copy = (TenantConfiguration)MemberwiseClone();
copy.DiagnosticsBlobSasUrl = Redact(copy.DiagnosticsBlobSasUrl);
copy.TeamsWebhookUrl = Redact(copy.TeamsWebhookUrl);
copy.WebhookUrl = Redact(copy.WebhookUrl);
copy.WebhookCustomHeadersJson = Redact(copy.WebhookCustomHeadersJson);
return copy;
static string Redact(string? value)
=> string.IsNullOrEmpty(value) ? (value ?? string.Empty) : Constants.RedactedSecretPlaceholder;
}
/// <summary>
/// Defense-in-depth for the redacted read-only view: for each secret string field, if THIS object
/// carries the <see cref="Constants.RedactedSecretPlaceholder"/> sentinel (i.e. a redacted config
/// was round-tripped back on a save), restore the real value from <paramref name="existing"/>.
/// Mutates this instance. Must mirror the field set in <see cref="RedactedCopyForReader"/>.
/// </summary>
public void RestoreRedactedSecretsFrom(TenantConfiguration existing)
{
if (existing == null) return;
if (DiagnosticsBlobSasUrl == Constants.RedactedSecretPlaceholder) DiagnosticsBlobSasUrl = existing.DiagnosticsBlobSasUrl;
if (TeamsWebhookUrl == Constants.RedactedSecretPlaceholder) TeamsWebhookUrl = existing.TeamsWebhookUrl;
if (WebhookUrl == Constants.RedactedSecretPlaceholder) WebhookUrl = existing.WebhookUrl;
if (WebhookCustomHeadersJson == Constants.RedactedSecretPlaceholder) WebhookCustomHeadersJson = existing.WebhookCustomHeadersJson;
}
/// <summary>
/// Returns the effective webhook URL and provider type, handling legacy TeamsWebhookUrl migration.
/// New fields take priority; falls back to TeamsWebhookUrl as TeamsLegacyConnector.
@@ -273,6 +273,9 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
// Save admin config
const handleSaveAdminConfig = useCallback(async () => {
if (!adminConfig) return;
// Platform settings are GA-only. A read-only Global Reader can reach the admin area (view scope)
// but must never persist global config — guard here too (the backend also enforces GlobalAdminOnly).
if (!isGlobalAdmin) return;
try {
setSavingConfig(true);
@@ -319,7 +322,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingConfig(false);
}
}, [adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
}, [isGlobalAdmin, adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
@@ -363,6 +366,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
// Save diagnostics paths
const handleSaveDiagPaths = useCallback(async (paths: DiagnosticsLogPath[]) => {
if (!adminConfig) return;
if (!isGlobalAdmin) return; // platform settings are GA-only (also route-gated + backend-enforced)
try {
setSavingDiagPaths(true);
setError(null);
@@ -394,7 +398,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingDiagPaths(false);
}
}, [adminConfig, getAccessToken]);
}, [isGlobalAdmin, adminConfig, getAccessToken]);
// Save ops alert config (rules + providers)
const handleSaveOpsAlertConfig = useCallback(async (
@@ -408,6 +412,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
newAutoActionDurationHours: number,
) => {
if (!adminConfig) return;
if (!isGlobalAdmin) return; // platform settings are GA-only (also route-gated + backend-enforced)
try {
setSavingOpsAlerts(true);
setError(null);
@@ -459,7 +464,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingOpsAlerts(false);
}
}, [adminConfig, getAccessToken]);
}, [isGlobalAdmin, adminConfig, getAccessToken]);
return (
<AdminConfigContext.Provider value={{
@@ -0,0 +1,7 @@
import { ProtectedRoute } from "@/components/ProtectedRoute";
// Backups (trigger backup, restore-row) are write operations — real Global Admin only. Nav-hidden for
// a read-only Global Reader; gate by route too.
export default function BackupsLayout({ children }: { children: React.ReactNode }) {
return <ProtectedRoute requireGlobalAdmin>{children}</ProtectedRoute>;
}
@@ -7,6 +7,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
import { trackEvent } from "@/lib/appInsights";
import { TenantConfiguration } from "./TenantManagementSection";
import { TenantSearchSelect } from "./TenantSearchSelect";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
interface ResolvedDevice {
sessionId: string;
@@ -55,6 +56,8 @@ export function DeviceBlockSection({
setError,
setSuccessMessage,
}: DeviceBlockSectionProps) {
// Read-only Global Readers reach this page (view scope) but must not block/kill devices.
const canMutate = useCanMutatePlatform();
const [blockSerialNumber, setBlockSerialNumber] = useState("");
const [blockTenantId, setBlockTenantId] = useState("");
const [blockDurationHours, setBlockDurationHours] = useState(12);
@@ -171,6 +174,7 @@ export function DeviceBlockSection({
};
const handleBlockDevice = async () => {
if (!canMutate) return; // read-only Global Reader
if (!blockSerialNumber.trim() || !blockTenantId.trim()) return;
if (blockAction === "Kill" && !confirm(
@@ -232,6 +236,7 @@ export function DeviceBlockSection({
};
const handleUnblockDevice = async (tenantId: string, serialNumber: string) => {
if (!canMutate) return; // read-only Global Reader
try {
setUnblockingDevice(serialNumber);
setError(null);
@@ -257,6 +262,7 @@ export function DeviceBlockSection({
};
const handleKillDevice = async (device: BlockedDevice) => {
if (!canMutate) return; // read-only Global Reader
if (!confirm(
`REMOTE KILL: This will permanently shut down the agent on device "${device.serialNumber}" and remove all agent files. This cannot be undone. Continue?`
)) return;
@@ -349,7 +355,7 @@ export function DeviceBlockSection({
</div>
<button
onClick={onSaveAdminConfig}
disabled={savingConfig || !adminConfigExists}
disabled={!canMutate || savingConfig || !adminConfigExists}
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 flex items-center space-x-2"
>
{savingConfig ? (
@@ -487,7 +493,7 @@ export function DeviceBlockSection({
)}
<button
onClick={handleBlockDevice}
disabled={blockingDevice || !blockSerialNumber.trim() || !blockTenantId}
disabled={!canMutate || blockingDevice || !blockSerialNumber.trim() || !blockTenantId}
className={`mt-4 px-4 py-2 text-white rounded-lg text-sm font-medium disabled:opacity-50 flex items-center space-x-2 ${
blockAction === "Kill"
? "bg-red-800 hover:bg-red-900 dark:bg-red-700 dark:hover:bg-red-800"
@@ -582,7 +588,7 @@ export function DeviceBlockSection({
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{d.reason || "\u2014"}</td>
<td className="px-3 py-2">
<div className="flex flex-col gap-1">
{d.action !== "Kill" && (
{canMutate && d.action !== "Kill" && (
<button
onClick={() => handleKillDevice(d)}
disabled={killingDevice === d.serialNumber}
@@ -591,13 +597,17 @@ export function DeviceBlockSection({
{killingDevice === d.serialNumber ? "Killing..." : "Kill"}
</button>
)}
<button
onClick={() => handleUnblockDevice(d.tenantId, d.serialNumber)}
disabled={unblockingDevice === d.serialNumber}
className="px-3 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"
>
{unblockingDevice === d.serialNumber ? "Removing..." : "Remove"}
</button>
{canMutate ? (
<button
onClick={() => handleUnblockDevice(d.tenantId, d.serialNumber)}
disabled={unblockingDevice === d.serialNumber}
className="px-3 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"
>
{unblockingDevice === d.serialNumber ? "Removing..." : "Remove"}
</button>
) : (
<span className="text-xs text-gray-400">Read-only</span>
)}
</div>
</td>
</tr>
@@ -6,6 +6,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
import { extractContinuation } from "@/lib/paginationLink";
import { isGuid } from "@/utils/inputValidation";
import { trackEvent } from "@/lib/appInsights";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
const PAGE_SIZE = 20;
@@ -73,6 +74,9 @@ export function SessionReportsSection({
getAccessToken,
setError,
}: SessionReportsSectionProps) {
// Admin Note is GA-only (PATCH session-reports/{id}/note is GlobalAdminOnly). A read-only Global
// Reader may view reports + notes but not edit them.
const canMutate = useCanMutatePlatform();
const [reports, setReports] = useState<SessionReport[]>([]);
const [loading, setLoading] = useState(false);
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
@@ -482,9 +486,10 @@ export function SessionReportsSection({
<textarea
value={adminNoteValue}
onChange={e => { setAdminNoteValue(e.target.value); setNoteSaveResult(null); }}
readOnly={!canMutate}
rows={3}
placeholder="Add an internal note about this report..."
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none placeholder:text-gray-400"
placeholder={canMutate ? "Add an internal note about this report..." : "No note"}
className="w-full text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none placeholder:text-gray-400 read-only:opacity-60 read-only:cursor-not-allowed"
/>
<div className="flex items-center justify-between mt-1.5">
<div className="text-xs">
@@ -502,8 +507,8 @@ export function SessionReportsSection({
</div>
<button
onClick={handleSaveAdminNote}
disabled={savingNote}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium"
disabled={!canMutate || savingNote}
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white rounded-md transition-colors text-xs font-medium disabled:cursor-not-allowed"
>
{savingNote ? (
<>
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
interface TenantAdmin {
tenantId: string;
@@ -27,6 +28,8 @@ export function TenantAdminSection({
setError,
setSuccessMessage,
}: TenantAdminSectionProps) {
// Read-only Global Readers may view tenant members but not add/remove/toggle them.
const canMutate = useCanMutatePlatform();
const [tenantAdmins, setTenantAdmins] = useState<TenantAdmin[]>([]);
const [loadingAdmins, setLoadingAdmins] = useState(false);
const [newAdminEmail, setNewAdminEmail] = useState("");
@@ -70,6 +73,7 @@ export function TenantAdminSection({
}, [tenantId]);
const handleAddTenantAdmin = async () => {
if (!canMutate) return; // read-only Global Reader — also closes the Enter-key path past disabled buttons
if (!newAdminEmail.trim()) return;
try {
@@ -108,6 +112,7 @@ export function TenantAdminSection({
};
const handleRemoveTenantAdmin = async (adminUpn: string) => {
if (!canMutate) return; // read-only Global Reader
if (!confirm(`Are you sure you want to remove ${adminUpn} as an admin?`)) {
return;
}
@@ -145,6 +150,7 @@ export function TenantAdminSection({
};
const handleToggleTenantAdmin = async (adminUpn: string, isEnabled: boolean) => {
if (!canMutate) return; // read-only Global Reader
try {
setTogglingAdmin(adminUpn);
setError(null);
@@ -184,6 +190,7 @@ export function TenantAdminSection({
};
const handleUpdatePermissions = async (adminUpn: string, role: string, canManageBootstrapTokens: boolean) => {
if (!canMutate) return; // read-only Global Reader (consistency with the other mutation handlers)
try {
setTogglingAdmin(adminUpn);
setError(null);
@@ -329,7 +336,7 @@ export function TenantAdminSection({
<select
value={effectiveRole}
onChange={(e) => handleUpdatePermissions(admin.upn, e.target.value, admin.canManageBootstrapTokens)}
disabled={togglingAdmin === admin.upn}
disabled={!canMutate || togglingAdmin === admin.upn}
className="px-2 py-1 text-xs border border-gray-300 rounded bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-purple-500 disabled:opacity-50"
>
<option value="Admin">Admin</option>
@@ -337,7 +344,7 @@ export function TenantAdminSection({
</select>
<button
onClick={() => handleToggleTenantAdmin(admin.upn, admin.isEnabled)}
disabled={togglingAdmin === admin.upn}
disabled={!canMutate || togglingAdmin === admin.upn}
className={`px-2 py-1 text-xs rounded hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed ${
admin.isEnabled
? 'bg-yellow-600 text-white hover:bg-yellow-700'
@@ -350,7 +357,7 @@ export function TenantAdminSection({
</button>
<button
onClick={() => handleRemoveTenantAdmin(admin.upn)}
disabled={removingAdmin === admin.upn}
disabled={!canMutate || removingAdmin === admin.upn}
className="px-2 py-1 text-xs bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{removingAdmin === admin.upn ? "..." : "Remove"}
@@ -366,7 +373,7 @@ export function TenantAdminSection({
type="checkbox"
checked={admin.canManageBootstrapTokens}
onChange={(e) => handleUpdatePermissions(admin.upn, "Operator", e.target.checked)}
disabled={togglingAdmin === admin.upn}
disabled={!canMutate || togglingAdmin === admin.upn}
className="h-4 w-4 text-purple-600 rounded border-gray-300 focus:ring-purple-500 disabled:opacity-50"
/>
<span className="text-sm text-gray-700">Can manage bootstrap tokens</span>
@@ -434,7 +441,7 @@ export function TenantAdminSection({
</select>
<button
onClick={() => handleAddTenantAdmin()}
disabled={addingAdmin || !newAdminEmail.trim()}
disabled={!canMutate || addingAdmin || !newAdminEmail.trim()}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
{addingAdmin ? "Adding..." : "Add"}
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { TenantAdminSection } from "./TenantAdminSection";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
export interface TenantConfiguration {
tenantId: string;
@@ -49,6 +50,8 @@ export function TenantManagementSection({
setError,
setSuccessMessage,
}: TenantManagementSectionProps) {
// Read-only Global Readers may view tenants (incl. config report) but not edit them.
const canMutate = useCanMutatePlatform();
const [searchQuery, setSearchQuery] = useState("");
const [showOnlyWaitlist, setShowOnlyWaitlist] = useState(false);
const [showOnlyReady, setShowOnlyReady] = useState(false);
@@ -90,6 +93,7 @@ export function TenantManagementSection({
}, [searchQuery, showOnlyWaitlist, showOnlyReady]);
const handleSaveTenant = async (tenant: TenantConfiguration) => {
if (!canMutate) return; // read-only Global Reader
try {
setSavingTenant(true);
setError(null);
@@ -127,6 +131,7 @@ export function TenantManagementSection({
};
const handleSendWelcomeEmail = async (tenantId: string, email?: string) => {
if (!canMutate) return; // read-only Global Reader
try {
setSendingWelcomeEmail(true);
setError(null);
@@ -161,6 +166,7 @@ export function TenantManagementSection({
};
const handleTogglePreview = async (tenantId: string) => {
if (!canMutate) return; // read-only Global Reader
try {
setTogglingPreviewTenant(tenantId);
setError(null);
@@ -356,7 +362,7 @@ export function TenantManagementSection({
<div className="flex items-center gap-2">
<button
onClick={() => handleTogglePreview(tenant.tenantId)}
disabled={togglingPreviewTenant === tenant.tenantId}
disabled={!canMutate || togglingPreviewTenant === tenant.tenantId}
className={`px-3 py-2 text-sm rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
previewApproved.has(tenant.tenantId)
? 'bg-amber-500 text-white hover:bg-amber-600'
@@ -514,7 +520,7 @@ export function TenantManagementSection({
/>
<button
onClick={() => handleSendWelcomeEmail(editingTenant.tenantId, notificationEmail)}
disabled={sendingWelcomeEmail || !notificationEmail.trim()}
disabled={!canMutate || sendingWelcomeEmail || !notificationEmail.trim()}
className="px-3 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors whitespace-nowrap flex items-center gap-1.5"
title="Send or resend the Private Preview welcome email"
>
@@ -621,7 +627,7 @@ export function TenantManagementSection({
</button>
<button
onClick={() => handleSaveTenant(editingTenant)}
disabled={savingTenant}
disabled={!canMutate || savingTenant}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 flex items-center space-x-2"
>
{savingTenant ? (
@@ -3,6 +3,7 @@
import { useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
interface BlockedVersion {
versionPattern: string;
@@ -23,6 +24,8 @@ export function VersionBlockSection({
setError,
setSuccessMessage,
}: VersionBlockSectionProps) {
// Read-only Global Readers may view block rules but not add/remove them.
const canMutate = useCanMutatePlatform();
const [versionPattern, setVersionPattern] = useState("");
const [versionAction, setVersionAction] = useState<"Block" | "Kill">("Block");
const [versionReason, setVersionReason] = useState("");
@@ -49,6 +52,7 @@ export function VersionBlockSection({
};
const handleAddRule = async () => {
if (!canMutate) return; // read-only Global Reader
if (!versionPattern.trim()) return;
if (versionAction === "Kill" && !confirm(
@@ -90,6 +94,7 @@ export function VersionBlockSection({
};
const handleRemoveRule = async (pattern: string) => {
if (!canMutate) return; // read-only Global Reader
try {
setRemovingPattern(pattern);
setError(null);
@@ -206,7 +211,7 @@ export function VersionBlockSection({
)}
<button
onClick={handleAddRule}
disabled={addingRule || !versionPattern.trim()}
disabled={!canMutate || addingRule || !versionPattern.trim()}
className={`mt-4 px-4 py-2 text-white rounded-lg text-sm font-medium disabled:opacity-50 flex items-center space-x-2 ${
versionAction === "Kill"
? "bg-red-800 hover:bg-red-900 dark:bg-red-700 dark:hover:bg-red-800"
@@ -263,13 +268,17 @@ export function VersionBlockSection({
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{v.createdByEmail}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{v.reason || "\u2014"}</td>
<td className="px-3 py-2">
<button
onClick={() => handleRemoveRule(v.versionPattern)}
disabled={removingPattern === v.versionPattern}
className="px-3 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"
>
{removingPattern === v.versionPattern ? "Removing..." : "Remove"}
</button>
{canMutate ? (
<button
onClick={() => handleRemoveRule(v.versionPattern)}
disabled={removingPattern === v.versionPattern}
className="px-3 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50"
>
{removingPattern === v.versionPattern ? "Removing..." : "Remove"}
</button>
) : (
<span className="text-xs text-gray-400">Read-only</span>
)}
</td>
</tr>
))}
@@ -6,6 +6,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
import SaveResetBar from "../../settings/components/SaveResetBar";
import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel";
import type { AdminConfiguration } from "@/types/adminConfig";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
interface VulnerabilityTopCve {
cveId: string;
@@ -44,6 +45,8 @@ export function VulnerabilityDataSection({
setSuccessMessage,
onAdminConfigUpdated,
}: VulnerabilityDataSectionProps) {
// Read-only Global Readers may view the exposure panel/data but not sync/reseed/save config.
const canMutate = useCanMutatePlatform();
// Local state for editable fields
const [nvdApiKey, setNvdApiKey] = useState(adminConfig?.nvdApiKey ?? "");
const [showApiKey, setShowApiKey] = useState(false);
@@ -416,8 +419,9 @@ export function VulnerabilityDataSection({
type="button"
role="switch"
aria-checked={vulnerabilityCorrelationEnabled}
disabled={!canMutate}
onClick={() => setVulnerabilityCorrelationEnabled(!vulnerabilityCorrelationEnabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 ${
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
vulnerabilityCorrelationEnabled
? "bg-emerald-600"
: "bg-gray-300 dark:bg-gray-600"
@@ -493,7 +497,7 @@ export function VulnerabilityDataSection({
<div className="pt-2 flex flex-wrap items-center gap-2">
<button
onClick={handleSync}
disabled={syncing || syncingMsrc || reseedingSeed || reseedingCommunity}
disabled={!canMutate || syncing || syncingMsrc || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
>
{syncing ? (
@@ -512,7 +516,7 @@ export function VulnerabilityDataSection({
</button>
<button
onClick={handleSyncMsrc}
disabled={syncingMsrc || syncing || reseedingSeed || reseedingCommunity}
disabled={!canMutate || syncingMsrc || syncing || reseedingSeed || reseedingCommunity}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-sm"
title="Refreshes the MSRC CVRF index (last 6 Patch Tuesdays from api.msrc.microsoft.com)"
>
@@ -532,7 +536,7 @@ export function VulnerabilityDataSection({
</button>
<button
onClick={() => handleReseed("seed")}
disabled={reseedingSeed || reseedingCommunity || syncing || syncingMsrc}
disabled={!canMutate || reseedingSeed || reseedingCommunity || syncing || syncingMsrc}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-amber-700 dark:text-amber-300 bg-transparent border border-amber-400 dark:border-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Re-imports CPE mapping seed from GitHub"
>
@@ -552,7 +556,7 @@ export function VulnerabilityDataSection({
</button>
<button
onClick={() => handleReseed("community")}
disabled={reseedingCommunity || reseedingSeed || syncing || syncingMsrc}
disabled={!canMutate || reseedingCommunity || reseedingSeed || syncing || syncingMsrc}
className="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-indigo-700 dark:text-indigo-300 bg-transparent border border-indigo-400 dark:border-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Re-imports community CPE mappings from GitHub (if available)"
>
@@ -598,6 +602,7 @@ export function VulnerabilityDataSection({
onSave={handleSave}
onReset={handleReset}
saving={saving}
canSave={canMutate}
/>
</div>
)}
@@ -0,0 +1,7 @@
import { ProtectedRoute } from "@/components/ProtectedRoute";
// Customs Archive review/delete is a destructive cleanup operation — real Global Admin only. Nav-hidden
// for a read-only Global Reader; gate by route too.
export default function CustomsArchiveLayout({ children }: { children: React.ReactNode }) {
return <ProtectedRoute requireGlobalAdmin>{children}</ProtectedRoute>;
}
@@ -5,7 +5,10 @@ import { AdminConfigProvider } from "./AdminConfigContext";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<ProtectedRoute requireGlobalAdmin>
// Platform scope (Global Admin OR read-only Global Reader) may enter the admin area to VIEW
// cross-tenant data. The platform-settings sub-pages are nav-hidden for a reader and remain
// gated on the real Global-Admin status (AdminConfigContext load/save) + backend (redaction/403).
<ProtectedRoute requireGlobalScope>
<AdminConfigProvider>
{children}
</AdminConfigProvider>
@@ -0,0 +1,7 @@
import { ProtectedRoute } from "@/components/ProtectedRoute";
// Ops (maintenance trigger, reseed, session cleanup/cascade-delete) are destructive platform
// operations — real Global Admin only. Nav-hidden for a read-only Global Reader; gate by route too.
export default function OpsLayout({ children }: { children: React.ReactNode }) {
return <ProtectedRoute requireGlobalAdmin>{children}</ProtectedRoute>;
}
@@ -1,5 +1,13 @@
import { ProtectedRoute } from "@/components/ProtectedRoute";
import { SettingsSidebar } from "./SettingsSidebar";
// Platform settings are GA-only. A read-only Global Reader reaches /admin (view scope) but must not
// open the settings sub-pages even by direct URL — gate the whole subtree on the real Global Admin
// role (the section is also nav-hidden for readers, and the backend redacts/403s).
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return <SettingsSidebar>{children}</SettingsSidebar>;
return (
<ProtectedRoute requireGlobalAdmin>
<SettingsSidebar>{children}</SettingsSidebar>
</ProtectedRoute>
);
}
@@ -0,0 +1,7 @@
import { ProtectedRoute } from "@/components/ProtectedRoute";
// Software mapping is vulnerability-data curation (write-heavy CPE mapping / ignore) — real Global
// Admin only. Nav-hidden for a read-only Global Reader; gate by route too.
export default function SoftwareLayout({ children }: { children: React.ReactNode }) {
return <ProtectedRoute requireGlobalAdmin>{children}</ProtectedRoute>;
}
@@ -80,7 +80,10 @@ export default function AnalyzeRulesPage() {
// Global admin tenant scope (tenant list, selector state, override/effective tenant)
const scope = useGlobalAdminScope();
const { isGlobalOverride, effectiveTenantId } = scope;
const isReadOnly = !user?.isTenantAdmin && !user?.isGlobalAdmin;
// Editable only for a real Global Admin (any tenant), or an own-tenant admin viewing their OWN tenant.
// A read-only Global Reader — and an own-tenant admin viewing a FOREIGN tenant (cross-tenant override) —
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
const fetchRules = useCallback(async () => {
if (!effectiveTenantId) return;
@@ -22,6 +22,7 @@ type AddNotification = (
interface User {
isTenantAdmin?: boolean;
isGlobalAdmin?: boolean;
isGlobalReader?: boolean;
role?: string | null;
}
@@ -350,7 +351,7 @@ export function useDashboardSessions({
// Initial fetch — gated on user role + tenant readiness
useEffect(() => {
if (user && !user.isTenantAdmin && !user.isGlobalAdmin && user.role !== "Operator") {
if (user && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && user.role !== "Operator") {
return; // regular users are redirected elsewhere; don't fetch
}
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
@@ -133,7 +133,7 @@ function HomeContent() {
// Stats cards: server-side aggregation so the numbers don't drift with whatever
// the client has paginated into view. Refreshes on SignalR newSession/newevents
// (debounced) and on SignalR reconnect to recover from any missed messages.
const isRegularUser = !!user && !user.isTenantAdmin && !user.isGlobalAdmin && user.role !== "Operator";
const isRegularUser = !!user && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && user.role !== "Operator";
const { stats: dashboardStats } = useDashboardStats({
tenantId,
globalAdminMode,
@@ -144,9 +144,10 @@ function HomeContent() {
disabled: isRegularUser,
});
// Redirect regular users (non-admin, non-operator) to progress portal – they must never see the session list
// Redirect regular users (non-admin, non-operator, no platform scope) to the progress portal — they
// must never see the session list. A read-only Global Reader has cross-tenant read scope → stays.
useEffect(() => {
if (user && !user.isTenantAdmin && !user.isGlobalAdmin && user.role !== 'Operator') {
if (user && !user.isTenantAdmin && !user.isGlobalAdmin && !user.isGlobalReader && user.role !== 'Operator') {
router.replace("/progress");
}
}, [user, router]);
@@ -154,10 +155,11 @@ function HomeContent() {
const serialValidationEnabled = useTenantSecurityConfig(tenantId, user, getAccessToken, addNotification);
const tenantList = useTenantList(globalAdminMode, getAccessToken);
// Disable global admin mode if user is not a global admin
// Disable global-scope mode for users without platform scope. A read-only Global Reader keeps it
// (their cross-tenant view is read-only-safe; writes are gated separately + backend-enforced).
useEffect(() => {
if (user && !user.isGlobalAdmin && globalAdminMode) {
console.log('[Home] User is not a global admin, disabling global admin mode');
if (user && !user.isGlobalAdmin && !user.isGlobalReader && globalAdminMode) {
console.log('[Home] User has no platform scope, disabling global mode');
setGlobalAdminMode(false);
}
}, [user, globalAdminMode]);
@@ -69,7 +69,10 @@ export default function GatherRulesPage() {
// Global admin tenant scope (tenant list, selector state, override/effective tenant)
const scope = useGlobalAdminScope();
const { isGlobalOverride, effectiveTenantId } = scope;
const isReadOnly = !user?.isTenantAdmin && !user?.isGlobalAdmin;
// Editable only for a real Global Admin (any tenant), or an own-tenant admin viewing their OWN tenant.
// A read-only Global Reader — and an own-tenant admin viewing a FOREIGN tenant (cross-tenant override) —
// is read-only. Backend also enforces (rules write is TenantAdminOrGA, cross-tenant blocked for non-GA).
const isReadOnly = !(user?.isGlobalAdmin || (user?.isTenantAdmin && !isGlobalOverride));
const fetchRules = useCallback(async () => {
if (!effectiveTenantId) return;
@@ -6,9 +6,11 @@ interface SaveResetBarProps {
onSave: () => Promise<void> | void;
onReset: () => void;
saving: boolean;
/** When false, Save/Reset are disabled (read-only viewer). Defaults to true. */
canSave?: boolean;
}
export default function SaveResetBar({ onSave, onReset, saving }: SaveResetBarProps) {
export default function SaveResetBar({ onSave, onReset, saving, canSave = true }: SaveResetBarProps) {
const [saveResult, setSaveResult] = useState<"idle" | "saved" | "error">("idle");
const handleSave = useCallback(async () => {
@@ -25,6 +27,9 @@ export default function SaveResetBar({ onSave, onReset, saving }: SaveResetBarPr
return (
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200">
{!canSave && (
<span className="text-sm text-gray-400">Read-only</span>
)}
{/* Inline feedback (errors only — success is shown via button state) */}
{saveResult === "error" && (
<span className="flex items-center text-sm text-red-600 font-medium">
@@ -36,14 +41,14 @@ export default function SaveResetBar({ onSave, onReset, saving }: SaveResetBarPr
)}
<button
onClick={onReset}
disabled={saving}
disabled={saving || !canSave}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm"
>
Reset
</button>
<button
onClick={handleSave}
disabled={saving}
disabled={saving || !canSave}
className={`px-4 py-2 rounded-md text-sm text-white font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center space-x-2 ${
saveResult === "saved" ? "bg-emerald-600" : "bg-indigo-600 hover:bg-indigo-700"
}`}
@@ -33,7 +33,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
mobileDrawerOpen, setMobileDrawerOpen,
} = useSidebar();
const { isAuthenticated, user } = useAuth();
const { isAuthenticated, user, hasGlobalScope } = useAuth();
const pathname = usePathname();
// Track desktop breakpoint
@@ -182,7 +182,9 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
switch (group.visibility) {
case "all": return true;
case "adminOrOperator": return isAdminOrOperator;
case "globalAdmin": return isGlobalAdmin && globalAdminMode;
// Cross-tenant nav is a VISIBILITY surface → any platform scope (GA or read-only GlobalReader).
// Mutating controls inside these views are gated separately on isGlobalAdmin.
case "globalAdmin": return hasGlobalScope && globalAdminMode;
default: return false;
}
};
@@ -201,6 +203,9 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
const filteredItems = group.items
.filter((item) => {
if (item.id === "cfg-reporting") return hasMcpAccess;
// Platform-settings/mutation sub-sections are real-GA-only: hide from a read-only
// Global Reader (who reaches the group via hasGlobalScope visibility).
if ("visibility" in item && item.visibility === "globalAdminOnly") return isGlobalAdmin;
return true;
})
.map((item) => {
@@ -387,8 +392,8 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
const visibleGroups = NAV_GROUPS.filter(isGroupVisible);
const hasPageSections = pageSections.length > 0;
// Regular users see minimal nav
const isRegularUser = !isAdminOrOperator && !isGlobalAdmin;
// Regular users see minimal nav. A read-only Global Reader has platform scope → full nav.
const isRegularUser = !isAdminOrOperator && !hasGlobalScope;
const renderNavContent = (isMobile = false) => (
<>
@@ -13,7 +13,7 @@ import { useAdminMode } from '@/hooks/useAdminMode';
import GlobalSearch from './GlobalSearch';
export default function Navbar() {
const { isAuthenticated, user, logout } = useAuth();
const { isAuthenticated, user, hasGlobalScope, logout } = useAuth();
const { notifications, unreadCount, markAsRead, markAllAsRead, removeNotification, clearAll } = useNotifications();
const { notifications: globalNotifications, dismissNotification: dismissGlobal, dismissAll: dismissAllGlobal } = useGlobalNotifications();
const { tenantNotifications, dismissTenantNotification, dismissAllTenant } = useTenantNotifications();
@@ -150,8 +150,9 @@ export default function Navbar() {
// bell as read-only. See TenantNotificationContext + EndpointAccessPolicyCatalog.
const canDismissTenant = isTenantAdmin || (user?.isGlobalAdmin ?? false);
// Regular users (non-Admin, non-Operator): show minimal navbar with only Progress Portal
if (!isAdminOrOperator && !user?.isGlobalAdmin) {
// Regular users (non-Admin, non-Operator, no platform scope): show minimal navbar with only
// Progress Portal. A read-only Global Reader has platform scope → gets the full navbar.
if (!isAdminOrOperator && !hasGlobalScope) {
return (
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-30">
<div className="px-3">
@@ -270,7 +271,7 @@ export default function Navbar() {
<path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg>
{(() => {
const globalCount = (user?.isGlobalAdmin && globalAdminMode) ? globalNotifications.length : 0;
const globalCount = (hasGlobalScope && globalAdminMode) ? globalNotifications.length : 0;
const totalUnread = unreadCount + globalCount + tenantNotifications.length;
return totalUnread > 0 ? (
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center w-4 h-4 text-[10px] font-bold leading-none text-white bg-red-600 rounded-full">
@@ -282,24 +283,33 @@ export default function Navbar() {
{/* Notification Dropdown */}
{showNotifications && (() => {
const showGlobal = user?.isGlobalAdmin && globalAdminMode;
const showGlobal = hasGlobalScope && globalAdminMode;
const visibleGlobal = showGlobal ? globalNotifications : [];
const hasAny = notifications.length > 0 || visibleGlobal.length > 0 || tenantNotifications.length > 0;
// "Clear all" should only show when the caller can actually clear something: ephemeral
// (client-side, anyone), tenant (if canDismissTenant), or global (real GA only). A read-only
// Global Reader viewing only global notifications gets no dead button.
const hasClearable =
notifications.length > 0 ||
(canDismissTenant && tenantNotifications.length > 0) ||
(!!user?.isGlobalAdmin && visibleGlobal.length > 0);
return (
<div className="fixed sm:absolute top-16 sm:top-auto left-2 right-2 sm:left-auto sm:right-0 mt-0 sm:mt-2 sm:w-96 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[calc(100vh-5rem)] sm:max-h-96 overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
{hasAny && (
{(unreadCount > 0 || hasClearable) && (
<div className="flex space-x-2">
{unreadCount > 0 && (
<button onClick={markAllAsRead} className="text-xs text-blue-600 hover:text-blue-800">
Mark all read
</button>
)}
<button onClick={() => { clearAll(); if (showGlobal) dismissAllGlobal(); if (canDismissTenant && tenantNotifications.length > 0) dismissAllTenant(); }} className="text-xs text-gray-500 hover:text-gray-700">
Clear all
</button>
{hasClearable && (
<button onClick={() => { clearAll(); if (showGlobal && user?.isGlobalAdmin) dismissAllGlobal(); if (canDismissTenant && tenantNotifications.length > 0) dismissAllTenant(); }} className="text-xs text-gray-500 hover:text-gray-700">
Clear all
</button>
)}
</div>
)}
</div>
@@ -361,11 +371,13 @@ export default function Navbar() {
<p className="text-[10px] text-gray-400 mt-1">{formatTime(new Date(gn.createdAt))}</p>
</div>
</div>
<button onClick={(e) => { e.stopPropagation(); dismissGlobal(gn.id); }} className="ml-2 text-gray-300 hover:text-gray-500" title="Dismiss">
<svg className="w-3.5 h-3.5" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
{user?.isGlobalAdmin && (
<button onClick={(e) => { e.stopPropagation(); dismissGlobal(gn.id); }} className="ml-2 text-gray-300 hover:text-gray-500" title="Dismiss">
<svg className="w-3.5 h-3.5" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" viewBox="0 0 24 24" stroke="currentColor">
<path d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
)}
</div>
</div>
))}
@@ -441,15 +453,15 @@ export default function Navbar() {
</>
)}
{/* Global Admin Toggle */}
{user?.isGlobalAdmin && (
{/* Global scope toggle — Global Admin or read-only Global Reader */}
{hasGlobalScope && (
<div className="mb-1">
<div className="flex items-center justify-between py-2 px-2.5 rounded-md bg-purple-50">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm text-gray-700">Global Admin</span>
<span className="text-sm text-gray-700">{user?.isGlobalAdmin ? 'Global Admin' : 'Global View'}</span>
{globalAdminMode && <span className="text-[10px] text-purple-700 font-semibold">ON</span>}
</div>
<button onClick={() => { trackEvent("admin_mode_toggled", { enabled: !globalAdminMode, isGlobal: true }); setGlobalAdminMode(!globalAdminMode); }} className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${globalAdminMode ? 'bg-purple-600' : 'bg-gray-300'}`}>
@@ -652,14 +664,14 @@ export default function Navbar() {
</div>
)}
{/* Global Admin Toggle */}
{user?.isGlobalAdmin && (
{/* Global scope toggle — Global Admin or read-only Global Reader */}
{hasGlobalScope && (
<div className="flex items-center justify-between py-2 px-2.5 rounded-md bg-purple-50 dark:bg-purple-900/30 mb-1">
<div className="flex items-center gap-1.5">
<svg className="w-4 h-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm text-gray-700 dark:text-gray-200">Global Admin</span>
<span className="text-sm text-gray-700 dark:text-gray-200">{user?.isGlobalAdmin ? 'Global Admin' : 'Global View'}</span>
{globalAdminMode && <span className="text-[10px] text-purple-700 font-semibold">ON</span>}
</div>
<button onClick={() => { trackEvent("admin_mode_toggled", { enabled: !globalAdminMode, isGlobal: true }); setGlobalAdminMode(!globalAdminMode); }} className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${globalAdminMode ? 'bg-purple-600' : 'bg-gray-300'}`}>
@@ -742,6 +754,11 @@ export default function Navbar() {
Global Admin
</span>
)}
{user?.isGlobalReader && !user?.isGlobalAdmin && (
<span className="inline-block mt-1.5 px-1.5 py-0.5 text-[10px] font-semibold text-purple-800 bg-purple-50 rounded-full">
Global Reader
</span>
)}
{isOperator && (
<span className="inline-block mt-1.5 px-1.5 py-0.5 text-[10px] font-semibold text-blue-800 bg-blue-100 rounded-full">
Operator
@@ -8,14 +8,21 @@ import { savePostLoginReturnUrl } from "../lib/postLoginReturn";
interface ProtectedRouteProps {
children: React.ReactNode;
requireGlobalAdmin?: boolean;
/**
* Require platform scope: Global Admin OR the read-only Global Reader. Use for cross-tenant areas
* a reader may VIEW (e.g. the /admin data pages). Within such areas, mutating controls + the
* platform-settings sub-pages stay gated on the real Global-Admin status (nav-hidden for readers,
* backend-enforced 403/redaction).
*/
requireGlobalScope?: boolean;
}
/**
* Protects routes by requiring authentication
* Optionally requires Global Admin role
* Optionally requires Global Admin role, or platform scope (Global Admin or read-only Global Reader).
*/
export function ProtectedRoute({ children, requireGlobalAdmin = false }: ProtectedRouteProps) {
const { isAuthenticated, user, isLoading, login } = useAuth();
export function ProtectedRoute({ children, requireGlobalAdmin = false, requireGlobalScope = false }: ProtectedRouteProps) {
const { isAuthenticated, user, hasGlobalScope, isLoading, login } = useAuth();
const router = useRouter();
// Once authenticated, remember it so transient auth-state flips (e.g. MSAL
@@ -69,8 +76,12 @@ export function ProtectedRoute({ children, requireGlobalAdmin = false }: Protect
return null;
}
// Show nothing if requires global admin but user is not (will redirect)
if (requireGlobalAdmin && user && !user.isGlobalAdmin) {
// Show nothing if the route's platform requirement isn't met.
// requireGlobalAdmin → real Global Admin only; requireGlobalScope → Global Admin OR Global Reader.
const platformDenied =
(requireGlobalAdmin && user && !user.isGlobalAdmin) ||
(requireGlobalScope && user && !hasGlobalScope);
if (platformDenied) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-md w-full text-center">
@@ -24,7 +24,7 @@ export function AuthGate() {
} else if (returnUrl) {
// Restore the deep link the user originally opened before re-auth.
router.replace(returnUrl);
} else if (user.isTenantAdmin || user.isGlobalAdmin || user.role === 'Operator') {
} else if (user.isTenantAdmin || user.isGlobalAdmin || user.isGlobalReader || user.role === 'Operator') {
router.push("/dashboard");
} else {
router.push("/progress");
@@ -90,6 +90,13 @@ interface UserInfo {
tenantId: string;
objectId: string;
isGlobalAdmin: boolean;
/**
* Read-only platform tier: cross-tenant VISIBILITY like a Global Admin but no platform mutations.
* ADDITIVE — a user may be both a GlobalReader and their own tenant's Admin (then isTenantAdmin is
* also true and they keep edit rights on their own tenant). Use {@link AuthContextType.hasGlobalScope}
* for visibility gating and isGlobalAdmin for platform-mutation gating.
*/
isGlobalReader: boolean;
isTenantAdmin: boolean;
role: 'Admin' | 'Operator' | 'Viewer' | null;
canManageBootstrapTokens: boolean;
@@ -101,6 +108,8 @@ interface UserInfo {
interface AuthContextType {
isAuthenticated: boolean;
user: UserInfo | null;
/** Platform-wide read scope: Global Admin OR read-only Global Reader. Use for cross-tenant VISIBILITY. */
hasGlobalScope: boolean;
isLoading: boolean;
isPreviewBlocked: boolean;
previewMessage: string;
@@ -152,6 +161,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
tenantId: (data.tenantId as string) || account.tenantId || '',
objectId: (data.objectId as string) || account.homeAccountId || '',
isGlobalAdmin: (data.isGlobalAdmin as boolean) || false,
isGlobalReader: (data.isGlobalReader as boolean) || false,
isTenantAdmin: (data.isTenantAdmin as boolean) || false,
role: (data.role as 'Admin' | 'Operator' | 'Viewer' | null) || null,
canManageBootstrapTokens: (data.canManageBootstrapTokens as boolean) || false,
@@ -205,6 +215,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
tenantId: account.tenantId || '',
objectId: account.homeAccountId || '',
isGlobalAdmin: false,
isGlobalReader: false,
isTenantAdmin: false,
role: null,
canManageBootstrapTokens: false,
@@ -225,6 +236,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
tenantId: data.tenantId || account.tenantId || '',
objectId: data.objectId || account.homeAccountId || '',
isGlobalAdmin: data.isGlobalAdmin || false,
isGlobalReader: data.isGlobalReader || false,
isTenantAdmin: data.isTenantAdmin || false,
role: data.role || null,
canManageBootstrapTokens: data.canManageBootstrapTokens || false,
@@ -257,6 +269,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
tenantId: account.tenantId || '',
objectId: account.homeAccountId || '',
isGlobalAdmin: false,
isGlobalReader: false,
isTenantAdmin: false,
role: null,
canManageBootstrapTokens: false,
@@ -393,6 +406,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
const value: AuthContextType = {
isAuthenticated,
user,
hasGlobalScope: (user?.isGlobalAdmin || user?.isGlobalReader) ?? false,
isLoading,
isPreviewBlocked,
previewMessage,
@@ -26,16 +26,20 @@ interface GlobalNotificationContextType {
const GlobalNotificationContext = createContext<GlobalNotificationContextType | undefined>(undefined);
export function GlobalNotificationProvider({ children }: { children: React.ReactNode }) {
const { user, getAccessToken } = useAuth();
const { user, hasGlobalScope, getAccessToken } = useAuth();
const { connection, isConnected, joinGroup, leaveGroup } = useSignalR();
const [notifications, setNotifications] = useState<GlobalNotification[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchingRef = useRef(false);
const isGlobal = user?.isGlobalAdmin === true;
// View scope (hasGlobalScope = GA OR read-only Global Reader) gates the read surfaces: FETCH/hydrate
// (backend global/notifications GET is GlobalReadOrAdmin) AND the SignalR global-admins live-group join
// (backend now admits HasGlobalScope — read broadcast, reader only receives). canManageGlobal (real GA)
// gates only dismiss/clear, which the backend keeps GlobalAdminOnly.
const canManageGlobal = user?.isGlobalAdmin === true;
const fetchNotifications = useCallback(async () => {
if (!isGlobal || fetchingRef.current) return;
if (!hasGlobalScope || fetchingRef.current) return;
fetchingRef.current = true;
try {
@@ -52,43 +56,45 @@ export function GlobalNotificationProvider({ children }: { children: React.React
} finally {
fetchingRef.current = false;
}
}, [isGlobal, getAccessToken]);
}, [hasGlobalScope, getAccessToken]);
// Initial state hydration. Re-fetched on SignalR reconnect to recover any deltas
// pushed during the disconnect window.
useEffect(() => {
if (!isGlobal) {
if (!hasGlobalScope) {
setNotifications([]);
return;
}
setIsLoading(true);
fetchNotifications().finally(() => setIsLoading(false));
}, [isGlobal, fetchNotifications]);
}, [hasGlobalScope, fetchNotifications]);
useEffect(() => {
if (!connection || !isGlobal) return;
if (!connection || !hasGlobalScope) return;
const handler = () => { fetchNotifications(); };
connection.onreconnected(handler);
}, [connection, isGlobal, fetchNotifications]);
}, [connection, hasGlobalScope, fetchNotifications]);
// Re-fetch when globalAdminMode is toggled in localStorage
useEffect(() => {
const handleStorageChange = () => {
if (isGlobal) {
if (hasGlobalScope) {
fetchNotifications();
}
};
window.addEventListener('localStorageChange', handleStorageChange);
return () => window.removeEventListener('localStorageChange', handleStorageChange);
}, [isGlobal, fetchNotifications]);
}, [hasGlobalScope, fetchNotifications]);
// Group membership: only Global Admins join the global-admins group.
// Group membership: any platform scope (GA or read-only Global Reader) joins the global-admins live
// group — it is a READ broadcast group (backend now admits HasGlobalScope), so a reader receives live
// notification pushes too. Dismiss/clear remain GA-only (guarded above).
useEffect(() => {
if (!isConnected || !isGlobal) return;
if (!isConnected || !hasGlobalScope) return;
const group = 'global-admins';
joinGroup(group);
return () => { leaveGroup(group); };
}, [isConnected, isGlobal, joinGroup, leaveGroup]);
}, [isConnected, hasGlobalScope, joinGroup, leaveGroup]);
// Live-push handlers.
useEffect(() => {
@@ -118,6 +124,9 @@ export function GlobalNotificationProvider({ children }: { children: React.React
}, [connection]);
const dismissNotification = useCallback(async (id: string) => {
// Dismiss is GA-only (backend GlobalAdminOnly). A read-only Global Reader must not even optimistically
// clear their view. UI dismiss buttons are also hidden for readers.
if (!canManageGlobal) return;
setNotifications(prev => prev.filter(n => n.id !== id));
try {
@@ -129,9 +138,10 @@ export function GlobalNotificationProvider({ children }: { children: React.React
} catch {
// Best-effort; backend push will reconcile other tabs if dismiss eventually lands
}
}, [getAccessToken]);
}, [canManageGlobal, getAccessToken]);
const dismissAll = useCallback(async () => {
if (!canManageGlobal) return; // GA-only (see dismissNotification)
setNotifications([]);
try {
@@ -143,7 +153,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React
} catch {
// Best-effort
}
}, [getAccessToken]);
}, [canManageGlobal, getAccessToken]);
const unreadCount = notifications.length;
@@ -60,10 +60,11 @@ export function useAggregatedAdminScope(opts?: {
urlTenantId?: string;
}): AggregatedAdminScope {
const { tenantId } = useTenant();
const { user } = useAuth();
const { hasGlobalScope } = useAuth();
const { globalAdminMode } = useAdminMode();
const isGlobalAdmin = Boolean(globalAdminMode && user?.isGlobalAdmin);
// Platform scope (GA or read-only Global Reader) — read-only-safe cross-tenant/aggregated view + selector.
const isGlobalAdmin = Boolean(globalAdminMode && hasGlobalScope);
const tenants = useTenantList(isGlobalAdmin);
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
@@ -0,0 +1,14 @@
"use client";
import { useAuth } from "@/contexts/AuthContext";
/**
* True only for a real platform Global Admin — the single signal that gates every MUTATING control in
* the cross-tenant /admin area. A read-only Global Reader reaches these pages (view scope via
* hasGlobalScope) but must NOT be able to block/kill devices, trigger maintenance, edit tenants, etc.
* The backend also enforces this (GlobalAdminOnly → 403); this hook is the UI read-only contract.
*/
export function useCanMutatePlatform(): boolean {
const { user } = useAuth();
return user?.isGlobalAdmin === true;
}
@@ -9,7 +9,12 @@ import { useTenantList, type TenantInfo } from "@/hooks/useTenantList";
export type { TenantInfo };
export interface GlobalAdminScope {
/** True when Global-Admin mode is toggled on AND the user actually is a global admin. */
/**
* True when global-scope mode is toggled on AND the user has platform scope (Global Admin OR the
* read-only Global Reader). This is a VISIBILITY/routing flag — it drives the tenant selector, the
* "Global view" banner and the `/global/` endpoint choice, all of which are read-only-safe. Mutating
* actions on these pages must gate separately on the real Global-Admin / own-tenant-admin status.
*/
isGlobalAdmin: boolean;
/** Sorted tenant list for the selector. Empty unless {@link isGlobalAdmin}. */
tenants: TenantInfo[];
@@ -36,10 +41,11 @@ export interface GlobalAdminScope {
*/
export function useGlobalAdminScope(): GlobalAdminScope {
const { tenantId } = useTenant();
const { user } = useAuth();
const { hasGlobalScope } = useAuth();
const { globalAdminMode } = useAdminMode();
const isGlobalAdmin = Boolean(globalAdminMode && user?.isGlobalAdmin);
// Platform scope (GA or read-only Global Reader) — read-only-safe cross-tenant view + selector.
const isGlobalAdmin = Boolean(globalAdminMode && hasGlobalScope);
const tenants = useTenantList(isGlobalAdmin);
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
@@ -101,6 +101,12 @@ export interface ExpandableNavItem {
label: string;
icon: React.ReactNode;
items: ExpandableSubItem[];
/**
* Item-level visibility within a platform-scope group. Default (undefined) = visible to any platform
* scope (Global Admin OR read-only Global Reader). "globalAdminOnly" = real Global Admin only — used for
* platform-settings/mutation sub-sections that a read-only Global Reader must not see.
*/
visibility?: "globalAdminOnly";
}
export interface NavGroup {
@@ -250,7 +256,9 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
],
},
{
id: "ga-settings", label: "Settings", icon: <GearIcon />,
// Platform settings are GA-only — a read-only Global Reader must not see them (their secrets
// are mutation surfaces; the reader gets redacted/read-only tenant config elsewhere).
id: "ga-settings", label: "Settings", icon: <GearIcon />, visibility: "globalAdminOnly",
items: [
{ id: "ga-global", label: "Global Settings", href: "/admin/settings/global" },
{ id: "ga-diag-paths", label: "Diagnostics Log Paths", href: "/admin/settings/diagnostics-log-paths" },
@@ -260,7 +268,9 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
],
},
{
id: "ga-ops", label: "Ops", icon: <WrenchIcon />,
// Ops + Backups + Customs Archive are destructive platform operations — GA-only, hidden from a
// read-only Global Reader (routes are also gated via requireGlobalAdmin layouts).
id: "ga-ops", label: "Ops", icon: <WrenchIcon />, visibility: "globalAdminOnly",
items: [
{ id: "ga-maintenance", label: "Maintenance", href: "/admin/ops" },
{ id: "ga-session-cleanup", label: "Session Cleanup", href: "/admin/ops/session-cleanup" },
@@ -269,7 +279,9 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
],
},
{
id: "ga-software", label: "Software", icon: <GlobeAltIcon />,
// Software mapping is vulnerability-data curation (write-heavy CPE mapping/ignore) — GA-only,
// hidden from a read-only Global Reader (route also gated via requireGlobalAdmin layout).
id: "ga-software", label: "Software", icon: <GlobeAltIcon />, visibility: "globalAdminOnly",
items: [
{ id: "ga-software-mapping", label: "Software Mapping", href: "/admin/software" },
],