diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs index fcdb3713..2bc2db5c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs @@ -219,6 +219,32 @@ public class InfrastructureWireParityTests }); } + [Fact] + public void CheckMcpAccessResponse_matches_the_member_shape() + { + // A tenant member reports the role of the token's tenant last; the MCP server gates the rule + // dry-run tools on it. + var payload = new Dictionary + { + ["allowed"] = true, + ["upn"] = "admin@contoso.com", + ["accessGrant"] = "AllMembers", + ["reason"] = "", + ["tenantRole"] = "Admin", + }; + + AssertParity( + payload, + new CheckMcpAccessResponse + { + Allowed = true, + Upn = "admin@contoso.com", + AccessGrant = "AllMembers", + Reason = "", + TenantRole = "Admin", + }); + } + [Fact] public void CheckMcpAccessResponse_matches_the_denied_shape() { @@ -244,6 +270,7 @@ public class InfrastructureWireParityTests GlobalRole = null, DelegatedTenantIds = null, DelegatedRole = null, + TenantRole = null, }); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceAllMembersTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceAllMembersTests.cs index f8107703..ca6b7de7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceAllMembersTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceAllMembersTests.cs @@ -76,6 +76,20 @@ public class McpUserServiceAllMembersTests Assert.Equal("AllMembers", result.AccessGrant); Assert.False(result.IsGlobalAdmin); Assert.Null(result.GlobalRole); + Assert.Equal(role, result.TenantRole); + } + + [Fact] + public async Task ClientCappedMember_ReportsViewer() + { + // The application cap applies to the reported role exactly as to every tenant endpoint, so a person + // reached through an admitted foreign client is never offered the Admin-only dry-run tools. + MemberOf(HomeTenant, Constants.TenantRoles.Admin); + + var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid, appRoles: null, clientCapped: true); + + Assert.True(result.IsAllowed); + Assert.Equal(Constants.TenantRoles.Viewer, result.TenantRole); } [Fact] @@ -144,13 +158,15 @@ public class McpUserServiceAllMembersTests Assert.True(result.IsAllowed); Assert.Equal(Constants.GlobalRoles.GlobalAdmin, result.AccessGrant); Assert.True(result.IsGlobalAdmin); + Assert.Null(result.TenantRole); Assert.Empty(_memberRoles.Lookups); } [Fact] public async Task DelegatedAdmin_WithoutHomeMembership_IsAllowed() { - // An MSP whose own home tenant is not a customer: no home role, but a curated delegated scope. + // An MSP whose own home tenant is not a customer: no home role, but a curated delegated scope. The + // home role is looked up only to report it — it grants nothing here. _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny())) .ReturnsAsync(new DelegatedScope(new Dictionary { @@ -162,7 +178,8 @@ public class McpUserServiceAllMembersTests Assert.True(result.IsAllowed); Assert.Equal("DelegatedAdmin", result.AccessGrant); Assert.Equal(new[] { ManagedTenant }, result.DelegatedTenantIds); - Assert.Empty(_memberRoles.Lookups); + Assert.Null(result.TenantRole); + Assert.Equal(new[] { (HomeTenant, Upn) }, _memberRoles.Lookups); } [Fact] @@ -175,7 +192,21 @@ public class McpUserServiceAllMembersTests Assert.True(result.IsAllowed); Assert.Equal("McpUser", result.AccessGrant); - Assert.Empty(_memberRoles.Lookups); + Assert.Null(result.TenantRole); + } + + [Fact] + public async Task EnabledMcpUsersRow_ReportsTheHomeRole() + { + MemberOf(HomeTenant, Constants.TenantRoles.Admin); + _adminRepo.Setup(x => x.GetMcpUserAsync(Upn)) + .ReturnsAsync(new McpUserEntry { Upn = Upn, IsEnabled = true }); + + var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid); + + Assert.True(result.IsAllowed); + Assert.Equal("McpUser", result.AccessGrant); + Assert.Equal(Constants.TenantRoles.Admin, result.TenantRole); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs index f21f7be3..982d5ae6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs @@ -273,6 +273,7 @@ public class McpUserFunction GlobalRole = string.IsNullOrEmpty(result.GlobalRole) ? null : result.GlobalRole, DelegatedTenantIds = result.DelegatedTenantIds is { Count: > 0 } ? result.DelegatedTenantIds : null, DelegatedRole = string.IsNullOrEmpty(result.DelegatedRole) ? null : result.DelegatedRole, + TenantRole = string.IsNullOrEmpty(result.TenantRole) ? null : result.TenantRole, }); return response; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs index 373bf621..0592445a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs @@ -139,6 +139,14 @@ public class McpUserService // The explicit grants below hold under BOTH remaining policies; AllMembers only ADDS the tenant-role // path at the end. Same order everywhere so the reported AccessGrant is policy-independent. + // The caller's member role in the tenant the token was issued for. Reported, never used to grant: + // the MCP server registers tools whose backend route needs a role (the rule dry-runs are + // TenantAdminOrGlobalReader) only for callers who hold it. Platform roles skip it — their global + // scope already covers those routes. + async Task HomeTenantRoleAsync() => string.IsNullOrWhiteSpace(homeTenantId) + ? null + : (await _memberRoleResolver.ResolveAsync(homeTenantId, upn, appRoles, clientCapped))?.Role; + // Any platform role (GlobalAdmin or read-only GlobalReader) always has MCP access // (unless explicitly disabled via the kill-switch above). if (globalRole != null) @@ -165,11 +173,13 @@ public class McpUserService if (!delegatedScope.IsEmpty) return McpAccessCheckResult.Allowed( upn, "DelegatedAdmin", isGlobalAdmin: false, globalRole: null, - delegatedTenantIds: delegatedTenantIds, delegatedRole: delegatedRole); + delegatedTenantIds: delegatedTenantIds, delegatedRole: delegatedRole, + tenantRole: await HomeTenantRoleAsync()); // An enabled McpUsers row is an explicit per-account grant under both policies. if (whitelistState == McpWhitelistState.Enabled) - return McpAccessCheckResult.Allowed(upn, "McpUser", false, null, delegatedTenantIds, delegatedRole); + return McpAccessCheckResult.Allowed( + upn, "McpUser", false, null, delegatedTenantIds, delegatedRole, await HomeTenantRoleAsync()); if (policy != McpAccessPolicy.AllMembers) // Surfaced verbatim to the end user by the MCP server's access guard. Keep it @@ -183,11 +193,9 @@ public class McpUserService // issued for — the same resolver the policy middleware uses on every tenant endpoint. A token from // an organization that never onboarded, or from an employee without a role (Progress Portal // end-users), resolves to no role and is denied here instead of failing on every tool call. - var memberRole = string.IsNullOrWhiteSpace(homeTenantId) - ? null - : await _memberRoleResolver.ResolveAsync(homeTenantId, upn, appRoles, clientCapped); + var memberRole = await HomeTenantRoleAsync(); if (memberRole != null) - return McpAccessCheckResult.Allowed(upn, "AllMembers", false, null, delegatedTenantIds, delegatedRole); + return McpAccessCheckResult.Allowed(upn, "AllMembers", false, null, delegatedTenantIds, delegatedRole, memberRole); return McpAccessCheckResult.Denied(Constants.PrincipalKeys.IsApplication(upn) ? "Service principal not enabled for MCP usage (the application is not a member of its tenant — ask a Tenant Admin to add it under Members as a service principal)" : "User not enabled for MCP usage (account has no role in its organization's tenant — ask a Tenant Admin to add you)"); @@ -374,13 +382,20 @@ public class McpAccessCheckResult /// public string? DelegatedRole { get; init; } + /// + /// The caller's member role ("Admin" | "Operator" | "Viewer") in the tenant the token was issued for, + /// client-capped; null without a role and for platform roles. Never a grant by itself. + /// + public string? TenantRole { get; init; } + public static McpAccessCheckResult Allowed( string upn, string accessGrant, bool isGlobalAdmin = false, string? globalRole = null, IReadOnlyCollection? delegatedTenantIds = null, - string? delegatedRole = null) => new() + string? delegatedRole = null, + string? tenantRole = null) => new() { IsAllowed = true, Upn = upn, @@ -388,7 +403,8 @@ public class McpAccessCheckResult IsGlobalAdmin = isGlobalAdmin, GlobalRole = globalRole, DelegatedTenantIds = delegatedTenantIds, - DelegatedRole = delegatedRole + DelegatedRole = delegatedRole, + TenantRole = tenantRole }; public static McpAccessCheckResult Denied(string reason) => new() diff --git a/src/McpServer/autopilot-monitor-mcp/scripts/audit-catalog.ts b/src/McpServer/autopilot-monitor-mcp/scripts/audit-catalog.ts index 9f7829eb..31298c51 100644 --- a/src/McpServer/autopilot-monitor-mcp/scripts/audit-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/scripts/audit-catalog.ts @@ -26,10 +26,11 @@ const DEPS: ServerDeps = { docs: { vector: stub(), sections: ['concepts', 'trust'] }, }; -type Role = { label: string; isGlobalAdmin: boolean; isGlobalReader: boolean; delegatedTenantIds?: string[] }; +type Role = { label: string; isGlobalAdmin: boolean; isGlobalReader: boolean; delegatedTenantIds?: string[]; tenantRole?: string }; const ROLES: Role[] = [ { label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false }, { label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true }, + { label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin' }, { label: 'tenant-user', isGlobalAdmin: false, isGlobalReader: false }, { label: 'delegated', isGlobalAdmin: false, isGlobalReader: false, delegatedTenantIds: ['00000000-0000-0000-0000-000000000001'] }, ]; @@ -46,7 +47,7 @@ async function main(): Promise { const handler = createMcpRequestHandler(() => createServerForCaller(DEPS), { onerror: (e) => console.error(e.message) }); app.post('/mcp', (req, res) => runWithCaller( - { token: 't', isGlobalAdmin: role.isGlobalAdmin, isGlobalReader: role.isGlobalReader, upn: 'a@contoso.com', ...(role.delegatedTenantIds ? { delegatedTenantIds: role.delegatedTenantIds } : {}) } as never, + { token: 't', isGlobalAdmin: role.isGlobalAdmin, isGlobalReader: role.isGlobalReader, upn: 'a@contoso.com', ...(role.delegatedTenantIds ? { delegatedTenantIds: role.delegatedTenantIds } : {}), ...(role.tenantRole ? { tenantRole: role.tenantRole } : {}) } as never, () => handler(req, res), ), ); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/access-guard.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/access-guard.test.ts index 211a394a..59e9bf21 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/access-guard.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/access-guard.test.ts @@ -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, hasGlobalScope, getCurrentToken, isDelegated, getDelegatedTenantIds } = +const { isGlobalAdmin, hasGlobalScope, getCurrentToken, isDelegated, getDelegatedTenantIds, canRunRuleDryRun } = await import('../client.js'); /** Build an unsigned JWT-shaped token carrying the given claims. */ @@ -80,7 +80,7 @@ function mockRes(): CapturedRes { interface GuardOutcome { nextCalled: boolean; /** caller context captured INSIDE next() (only meaningful when nextCalled) */ - ctx?: { token: string | undefined; ga: boolean; scope?: boolean; delegated?: boolean; managed?: string[] }; + ctx?: { token: string | undefined; ga: boolean; scope?: boolean; delegated?: boolean; managed?: string[]; dryRun?: boolean }; status: number | null; body: unknown; headers: Record; @@ -117,6 +117,7 @@ function runGuard(req: Request): Promise { scope: hasGlobalScope(), delegated: isDelegated(), managed: getDelegatedTenantIds(), + dryRun: canRunRuleDryRun(), }, status: cap.statusCode, body: cap.body, @@ -290,7 +291,28 @@ 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, scope: true, delegated: false, managed: undefined }); + expect(out.ctx).toEqual({ token, ga: true, scope: true, delegated: false, managed: undefined, dryRun: true }); + }); + + it('threads tenantRole=Admin into the context: the rule dry-runs are offered without platform scope', async () => { + stubBackend({ body: { allowed: true, accessGrant: 'AllMembers', tenantRole: 'Admin' } }); + const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`)); + expect(out.nextCalled).toBe(true); + expect(out.ctx?.scope).toBe(false); + expect(out.ctx?.dryRun).toBe(true); + }); + + it('withholds the rule dry-runs from an Operator, a Viewer and a backend that sends no role', async () => { + for (const body of [ + { allowed: true, accessGrant: 'AllMembers', tenantRole: 'Operator' }, + { allowed: true, accessGrant: 'AllMembers', tenantRole: 'Viewer' }, + { allowed: true, accessGrant: 'AllMembers' }, + ]) { + stubBackend({ body }); + const out = await runGuard(mockReq(`Bearer ${validToken(uniqueUpn())}`)); + expect(out.nextCalled).toBe(true); + expect(out.ctx?.dryRun, JSON.stringify(body)).toBe(false); + } }); it('propagates isGlobalAdmin:false through the caller context', async () => { diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/catalog-budget.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/catalog-budget.test.ts index 394df654..bfd59639 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/catalog-budget.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/catalog-budget.test.ts @@ -34,14 +34,19 @@ interface Role { isGlobalAdmin: boolean; isGlobalReader: boolean; delegatedTenantIds?: string[]; + /** Member role in the token's tenant; "Admin" adds the rule dry-runs (client.ts canRunRuleDryRun). */ + tenantRole?: string; /** Ratchet: serialized `tools` array of tools/list, in characters (post-diet baseline 2026-09-22 + ~3 %). */ maxCatalogChars: number; } const ROLES: Role[] = [ { label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 92_000 }, { label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 77_000 }, - { label: 'tenant-user', isGlobalAdmin: false, isGlobalReader: false, maxCatalogChars: 53_000 }, - { label: 'delegated', isGlobalAdmin: false, isGlobalReader: false, delegatedTenantIds: ['11111111-1111-4111-8111-111111111111'], maxCatalogChars: 58_000 }, + { label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin', maxCatalogChars: 53_000 }, + // Lowered 2026-10-11: Operators/Viewers and delegated callers without the home Admin role no longer + // list the two rule dry-runs (measured 49,385 and 53,645 chars). + { label: 'tenant-user', isGlobalAdmin: false, isGlobalReader: false, maxCatalogChars: 51_000 }, + { label: 'delegated', isGlobalAdmin: false, isGlobalReader: false, delegatedTenantIds: ['11111111-1111-4111-8111-111111111111'], maxCatalogChars: 55_500 }, ]; /** @@ -79,6 +84,7 @@ beforeAll(async () => { isGlobalReader: role.isGlobalReader, upn: 'caller@contoso.com', ...(role.delegatedTenantIds ? { delegatedTenantIds: role.delegatedTenantIds } : {}), + ...(role.tenantRole ? { tenantRole: role.tenantRole } : {}), }, () => handler(req, res), ), diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts index a4cca65b..211524d4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-catalog-order.test.ts @@ -23,11 +23,11 @@ import { registerTools } from '../tools.js'; import { GA_STRICT_TOOL_NAMES } from '../tools/admin.js'; import type { SearchProvider } from '../search-provider.js'; -function registeredToolNames(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] { +function registeredToolNames(ga: boolean, strictGa: boolean = ga, delegated: boolean = false, ruleDryRun: 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, undefined, ga, strictGa, delegated); + registerTools(server, undefined, undefined, undefined, ga, strictGa, delegated, ruleDryRun); const internal = server as unknown as { _registeredTools: Record }; return Object.keys(internal._registeredTools); } @@ -177,10 +177,10 @@ describe('role catalog snapshot — privilege-leak guard', () => { 'search_sessions', 'search_sessions_by_cve', 'search_sessions_by_event', - // Rule-authoring trio, available to every role: validate_rule is MCP-local - // (no backend call at all); test_analyze_rule's dryrun endpoint and - // test_log_pattern's test-pattern endpoint enforce TenantAdminOrGlobalReader - // server-side (a Viewer's call 403s there). + // Rule-authoring trio: validate_rule is MCP-local (no backend call) and listed + // for every role; test_analyze_rule and test_log_pattern call routes that are + // TenantAdminOrGlobalReader, so they are listed only where the backend would + // answer (RULE_DRY_RUN below). 'test_analyze_rule', 'test_log_pattern', 'update_tenant_config', @@ -256,6 +256,10 @@ describe('role catalog snapshot — privilege-leak guard', () => { // subset-tier routes it calls to the caller's managed set (never a client-side fan-out). const DELEGATED_ADDED = ['get_fleet_overview', 'list_tenants']; + // Rule dry-runs: listed for platform roles and a Tenant Admin of the token's tenant only + // (ruleDryRun gate = client.ts canRunRuleDryRun); an Operator or Viewer would only collect a 403. + const RULE_DRY_RUN = ['test_analyze_rule', 'test_log_pattern']; + const without = (set: string[], remove: string[]) => set.filter((n) => !remove.includes(n)); it('Global Admin sees exactly the master set', () => { @@ -267,16 +271,26 @@ describe('role catalog snapshot — privilege-leak guard', () => { without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT, ...RULE_SUBMISSION_WRITE_GA_STRICT])); }); - it('tenant user = GA minus all platform-only tools', () => { - expect(registeredToolNames(false, false, false)).toEqual(without(GA_FULL, PLATFORM_ONLY)); + it('Tenant Admin = GA minus all platform-only tools', () => { + expect(registeredToolNames(false, false, false, true)).toEqual(without(GA_FULL, PLATFORM_ONLY)); }); - it('delegated (MSP) caller = tenant user minus the global IME archive, plus list_tenants + get_fleet_overview', () => { - const tenant = without(GA_FULL, PLATFORM_ONLY); + it('tenant Operator / Viewer = Tenant Admin minus the rule dry-runs', () => { + expect(registeredToolNames(false, false, false, false)).toEqual(without(GA_FULL, [...PLATFORM_ONLY, ...RULE_DRY_RUN])); + }); + + it('delegated (MSP) caller = tenant member minus the global IME archive, plus list_tenants + get_fleet_overview', () => { + const tenant = without(GA_FULL, [...PLATFORM_ONLY, ...RULE_DRY_RUN]); const expected = [...without(tenant, DELEGATED_HIDDEN), ...DELEGATED_ADDED].sort(); expect(registeredToolNames(false, false, true)).toEqual(expected); }); + it('a delegated caller who is Tenant Admin of the home tenant keeps the rule dry-runs', () => { + const tenant = without(GA_FULL, PLATFORM_ONLY); + const expected = [...without(tenant, DELEGATED_HIDDEN), ...DELEGATED_ADDED].sort(); + expect(registeredToolNames(false, false, true, true)).toEqual(expected); + }); + // ── Consistency of the difference lists themselves (catch stale entries). ── it('every difference-list entry is a real GA tool', () => { for (const [label, list] of [ @@ -287,6 +301,7 @@ describe('role catalog snapshot — privilege-leak guard', () => { ['PLATFORM_ONLY', PLATFORM_ONLY], ['DELEGATED_HIDDEN', DELEGATED_HIDDEN], ['DELEGATED_ADDED', DELEGATED_ADDED], + ['RULE_DRY_RUN', RULE_DRY_RUN], ] as const) { const stale = list.filter((n) => !GA_FULL.includes(n)); expect(stale, `${label} lists tools not present in the GA master set`).toEqual([]); diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-rules.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-rules.test.ts index 51861fe3..68c9e138 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-rules.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/tools-rules.test.ts @@ -15,10 +15,11 @@ vi.mock('../client.js', async (importOriginal) => { type Handler = (args: Record) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>; -function captureToolHandlers(ga: boolean): Record { +/** ruleDryRun defaults to true: the handler tests below act as a Tenant Admin, who gets all three tools. */ +function captureToolHandlers(ga: boolean, ruleDryRun: boolean = true): Record { const handlers: Record = {}; const fake = { registerTool: (name: string, _def: unknown, handler: Handler) => { handlers[name] = handler; } }; - registerRuleTools(fake as never, ga); + registerRuleTools(fake as never, ga, ruleDryRun); return handlers; } @@ -94,6 +95,23 @@ describe('test_analyze_rule', () => { }); }); +describe('registration by role', () => { + it('a Tenant Admin (or platform role) gets validate_rule plus both dry-runs', () => { + expect(Object.keys(captureToolHandlers(false, true)).sort()).toEqual(['test_analyze_rule', 'test_log_pattern', 'validate_rule']); + }); + + it('an Operator or Viewer gets validate_rule only, and its next step hands the draft to a Tenant Admin', async () => { + const handlers = captureToolHandlers(false, false); + expect(Object.keys(handlers)).toEqual(['validate_rule']); + + const res = await handlers.validate_rule({ rule: ANALYZE_DRAFT }); + const payload = JSON.parse(res.content[0].text); + expect(payload.valid).toBe(true); + expect(payload.nextStep).toContain('Tenant Admin'); + expect(payload.nextStep).not.toContain('Dry-run it now'); + }); +}); + describe('validate_rule', () => { it('is fully MCP-local (never calls the backend) and reports findings', async () => { const handlers = captureToolHandlers(false); diff --git a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts index 4f924d75..9037a611 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts @@ -89,6 +89,8 @@ interface AccessCacheEntry { // scope and downgrade the caller to home-tenant-only routing. delegatedTenantIds?: string[]; delegatedRole?: string; + // Member role in the token's tenant (gates the rule dry-run tools); cached for the same reason. + tenantRole?: string; // The backend refused the TOKEN itself (401) — cached like a deny so a replayed bad token costs // one backend call per TTL, but answered 401 + challenge, never "not enabled". unauthenticated?: boolean; @@ -112,6 +114,9 @@ interface AccessCheckResult { // routing bounded to this set; every delegated tool call must name one of these tenants. delegatedTenantIds?: string[]; delegatedRole?: string; + // "Admin" | "Operator" | "Viewer" in the tenant the token was issued for (client-capped by the + // backend); undefined without a role, for platform roles, and from a backend that predates the field. + tenantRole?: string; // The backend — the only party holding the JWKS — rejected the token (signature, issuer, // audience, lifetime). That is "invalid token": the spec says it MUST be answered 401 with a // WWW-Authenticate challenge so the client re-authenticates, not 403 ("insufficient @@ -169,6 +174,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis isGlobalReader: cached.isGlobalReader, delegatedTenantIds: cached.delegatedTenantIds, delegatedRole: cached.delegatedRole, + tenantRole: cached.tenantRole, unauthenticated: cached.unauthenticated, // Only genuine backend verdicts are ever cached (infra errors return early // below without caching), so a cache hit is never an infra error. @@ -235,6 +241,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis isGlobalReader: data.globalRole === 'GlobalReader', delegatedTenantIds: delegatedTenantIds && delegatedTenantIds.length > 0 ? delegatedTenantIds : undefined, delegatedRole: data.delegatedRole, + tenantRole: typeof data.tenantRole === 'string' && data.tenantRole ? data.tenantRole : undefined, // The backend reached a verdict — a deny here is a genuine authorization // decision (e.g. not whitelisted), not an infrastructure problem. infraError: false, @@ -251,6 +258,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis isGlobalReader: result.isGlobalReader, delegatedTenantIds: result.delegatedTenantIds, delegatedRole: result.delegatedRole, + tenantRole: result.tenantRole, expiresAt: Date.now() + ACCESS_CACHE_TTL_MS, }, MAX_ACCESS_CACHE_ENTRIES); return result; @@ -539,6 +547,7 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo isGlobalReader: result.isGlobalReader, delegatedTenantIds: result.delegatedTenantIds, delegatedRole: result.delegatedRole, + tenantRole: result.tenantRole, // Home tenant from the JWT `tid` (lowercased). Lets a delegated (MSP) admin who is also a member // of their own home tenant keep reading it via the tenant-scoped member path — see client.ts // enforceDelegatedTenant / pickGlobalOrTenantPath. Harmless for GA / plain tenant callers. diff --git a/src/McpServer/autopilot-monitor-mcp/src/client.ts b/src/McpServer/autopilot-monitor-mcp/src/client.ts index 80c0c1f7..597a0a7b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/client.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/client.ts @@ -89,6 +89,12 @@ interface CallerContext { delegatedTenantIds?: string[]; /** Strongest delegated role ("DelegatedAdmin" | "DelegatedReader"); informational (read-only server). */ delegatedRole?: string; + /** + * The caller's member role ("Admin" | "Operator" | "Viewer") in the tenant the token was issued for, as the + * backend's access check reports it (client-capped). Gates the tools whose backend route needs the + * Tenant Admin role — see `canRunRuleDryRun`. Absent without a role and for platform roles. + */ + tenantRole?: string; /** * The caller's home tenant (lowercase JWT `tid`). Set for every caller (harmless for GA / plain tenant * users, who never consult it). Its purpose is the delegated (MSP) case: a delegated caller who is ALSO @@ -178,6 +184,15 @@ export function hasGlobalScope(): boolean { return store?.isGlobalAdmin === true || store?.isGlobalReader === true; } +/** + * True when the current caller may run the rule dry-runs (test_analyze_rule / test_log_pattern): their + * backend routes are TenantAdminOrGlobalReader, so a platform role or the Tenant Admin role in the token's + * tenant. Everyone else would only collect a 403, so those tools are not registered for them. + */ +export function canRunRuleDryRun(): boolean { + return hasGlobalScope() || callerStore.getStore()?.tenantRole === 'Admin'; +} + /** * Managed tenant IDs (lowercase) for the current delegated (MSP) caller, or undefined when the caller is * not delegated / no context is active. diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index ddfac515..9571e391 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -1043,6 +1043,8 @@ export interface CheckMcpAccessResponse { delegatedTenantIds?: string[]; /** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */ delegatedRole?: string; + /** "Admin" | "Operator" | "Viewer": the caller's member role in the tenant the token was issued for (client-capped like every tenant endpoint); omitted without a role and for platform roles. The MCP server registers tools whose backend route needs a role (the rule dry-runs) only for callers holding it. */ + tenantRole?: string; } /** Configuration for optional agent collectors */ diff --git a/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts b/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts index d8c143c4..1812a91e 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/mcp-server-factory.ts @@ -10,7 +10,7 @@ import { registerTools } from './tools.js'; import { registerResources } from './resources.js'; import { registerPrompts } from './prompts.js'; import type { DocsSearchBundle, SearchProvider } from './search-provider.js'; -import { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds, getHomeTenantId } from './client.js'; +import { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds, getHomeTenantId, canRunRuleDryRun } from './client.js'; import { WEBSITE_BASE_URL } from './config.js'; /** @@ -116,7 +116,7 @@ const RESOURCE_CACHE_TTL_MS = 60 * 60 * 1000; * role: a non-Global-Admin never sees GA-only tools or any cross-tenant / GA * wording — reducing both confusion and attack surface. */ -export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean, delegated: boolean, managedTenants: string[], homeTenantId?: string): McpServer { +export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean, delegated: boolean, managedTenants: string[], homeTenantId?: string, ruleDryRun: boolean = ga): McpServer { const s = new McpServer( { ...SERVER_INFO, version: deps.serverVersion }, { @@ -148,14 +148,14 @@ export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean }, }, ); - registerTools(s, deps.knowledgeBase, deps.eventTypeIndex, deps.docs, ga, strictGa, delegated); + registerTools(s, deps.knowledgeBase, deps.eventTypeIndex, deps.docs, ga, strictGa, delegated, ruleDryRun); stripSchemaUrls(s); registerResources(s); // A delegated caller has no platform scope, so prompts get the tenant-user surface (ga=false) — // the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user. strictGa // gates the prompt steps that name the strictGa-only raw tools (a Reader must not be told to - // call tools it cannot see). - registerPrompts(s, ga, strictGa); + // call tools it cannot see); ruleDryRun does the same for the dry-run steps. + registerPrompts(s, ga, strictGa, ruleDryRun); return s; } @@ -178,6 +178,6 @@ export function createServerForCaller(deps: ServerDeps): McpServer { // Home tenant is only surfaced to a delegated caller (for the "you may also query your home tenant" hint); // GA / plain tenant users don't need it in their instructions. const homeTenantId = delegated ? getHomeTenantId() : undefined; - return createMcpServer(deps, ga, isGlobalAdmin(), delegated, managedTenants, homeTenantId); + return createMcpServer(deps, ga, isGlobalAdmin(), delegated, managedTenants, homeTenantId, canRunRuleDryRun()); } diff --git a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts index d5a95f59..d74c6bce 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts @@ -14,7 +14,20 @@ import { z } from 'zod'; * with Zod but keep types as strings; numeric/optional semantics are described * in the argument text for the model. */ -export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolean): void { +/** + * The closing step of the two investigation prompts: a cause that can recur becomes an analyze rule. The + * dry-run sentence names test_analyze_rule only for a caller it is registered for (ruleDryRun). + */ +function ruleFromFindingStep(n: number, ruleDryRun: boolean): string { + return `${n}. If the root cause could happen again and no existing rule names it, draft an analyze rule that would ` + + 'flag it next time: read get_resource(name="rule_authoring_guide") and check the draft with validate_rule. ' + + (ruleDryRun + ? 'Then dry-run it with test_analyze_rule against this session (it should fire) and against a successful ' + + 'session (it should stay silent), and hand over the final JSON.\n' + : 'Hand the validated JSON to a Tenant Admin, who can dry-run it and create it under Rules → Analyze Rules.\n'); +} + +export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolean, ruleDryRun: boolean = ga): void { server.registerPrompt( 'investigate-failed-session', { @@ -43,7 +56,8 @@ export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolea '3. Cross-check the rule analysis (it carries probable cause + remediation). If the summary ' + 'reported keyEventsTruncated, pull the raw events rather than trusting the capped list.\n\n' + 'Then report: (a) final outcome and phase reached, (b) the single most likely root cause with ' + - 'the event evidence that supports it, (c) concrete remediation steps, (d) confidence level.', + 'the event evidence that supports it, (c) concrete remediation steps, (d) confidence level.\n\n' + + ruleFromFindingStep(4, ruleDryRun), }, }, ], @@ -100,7 +114,8 @@ export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolea : '') + '\nThen report: (a) final outcome + phase reached, (b) the single most likely root cause with ' + 'the specific evidence (agent-log line AND/OR backend event), (c) concrete remediation, ' + - '(d) confidence level. Cite both client and backend sources where they corroborate.', + '(d) confidence level. Cite both client and backend sources where they corroborate.\n\n' + + ruleFromFindingStep(strictGa ? 5 : 4, ruleDryRun), }, }, ], @@ -174,12 +189,16 @@ export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolea '(evaluate existing events), or both? If both, draft the gather rule first and reference ' + 'its outputEventType from the analyze rule.\n' + '3. Draft the rule JSON and call validate_rule with it. Fix every error; explain warnings you keep.\n' + - (sessionId - ? `4. Dry-run the analyze rule: test_analyze_rule(sessionId="${sessionId}", rule=). ` + - 'Walk through the per-condition trace; iterate until the verdict matches the expectation, and check the interpolated explanation reads well.\n' - : '4. Ask me for a recent sessionId (one where the rule SHOULD fire and ideally one where it should not) and dry-run the analyze rule with test_analyze_rule.\n') + + (!ruleDryRun + ? '4. The dry-run (test_analyze_rule) needs the Tenant Admin role and is not available to me. Point out the ' + + 'session where the rule should fire, so a Tenant Admin can dry-run the draft before creating it.\n' + : sessionId + ? `4. Dry-run the analyze rule: test_analyze_rule(sessionId="${sessionId}", rule=). ` + + 'Walk through the per-condition trace; iterate until the verdict matches the expectation, and check the interpolated explanation reads well.\n' + : '4. Ask me for a recent sessionId (one where the rule SHOULD fire and ideally one where it should not) and dry-run the analyze rule with test_analyze_rule.\n') + '5. Present the final rule JSON with a short explanation of when it fires, its confidence ' + - 'model, and how to create it in the portal (Settings → Gather Rules / Analyze Rules).', + 'model, and how to create it in the portal (Rules → Gather Rules / Analyze Rules, Create Custom Rule ' + + 'in JSON mode).', }, }, ], diff --git a/src/McpServer/autopilot-monitor-mcp/src/resources.ts b/src/McpServer/autopilot-monitor-mcp/src/resources.ts index c1992555..c91001b0 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resources.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resources.ts @@ -83,7 +83,7 @@ export function registerResources(server: McpServer): void { 'Complete guide for authoring gather and analyze rules: collector types, triggers, ' + 'condition sources, operators, confidence model, {{token}} interpolation, guardrail ' + 'semantics and common pitfalls. Read together with rule_schemas and rule_guardrails; ' + - 'validate drafts with validate_rule and dry-run analyze rules with test_analyze_rule.', + 'validate drafts with validate_rule and dry-run analyze rules with test_analyze_rule (Tenant Admin).', }, async () => ({ contents: [ diff --git a/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts b/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts index 31f3fb4b..5c77fda8 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts @@ -33,13 +33,17 @@ export const RULE_AUTHORING_GUIDE = { '4. For analyze rules: call test_analyze_rule with the draft plus a sessionId of a real ' + 'session from your tenant (ideally one where the rule SHOULD fire and one where it should ' + 'NOT). The dry-run returns a per-condition trace with evidence — iterate until the verdict ' + - 'matches your expectation. Nothing is persisted by a dry-run.', + 'matches your expectation. Nothing is persisted by a dry-run. test_analyze_rule and ' + + 'test_log_pattern are listed for Tenant Admins and platform roles only; other roles hand the ' + + 'validated draft to a Tenant Admin.', '5. Gather rules cannot be dry-run against a session (they execute on devices). For logparser ' + 'rules, test the regex with test_log_pattern against sample lines pasted from the real log ' + 'file — it uses the agent\'s exact .NET matching semantics. Then deploy to a test device/VM; ' + 'the agent debug log setting for gather rules shows per-rule execution detail on-device.', - '6. Create the finished rule in the portal (Settings → Gather Rules / Analyze Rules). Custom ' + - 'rules are tenant-scoped. Well-tested rules can be proposed as community rules via GitHub.', + '6. Create the finished rule in the portal (Rules → Gather Rules / Analyze Rules; Create Custom ' + + 'Rule accepts the JSON, and the analyze-rule editor can test it against a session). Custom rules ' + + 'are tenant-scoped. A Tenant Admin can submit well-tested rules to the community pool from the ' + + 'same page; accepted rules ship to every tenant after review.', ], ids: { diff --git a/src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts b/src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts index 0d3cd87d..2c521595 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts @@ -636,7 +636,7 @@ function lintLogparserParameters(rule: Record): ValidationFindi level: 'info', message: 'logparser matching is case-SENSITIVE and runs on the agent\'s .NET regex engine — a pattern verified in a JS/PHP/Python tester can behave differently. ' + - 'Test it with test_log_pattern(pattern, sampleLines) against real lines from the log file before deploying.', + 'Test it with test_log_pattern(pattern, sampleLines) against real lines from the log file before deploying (Tenant Admin).', }); const format = parameters.format; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools.ts b/src/McpServer/autopilot-monitor-mcp/src/tools.ts index 00854fe8..020d394f 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools.ts @@ -28,6 +28,10 @@ import type { DocsSearchBundle } from './search-provider.js'; * express on its own: hiding the one ungated platform-only tool (get_ime_version_history) and exposing a * required `tenantId` selector on get_software_inventory. A caller that is BOTH platform AND delegated is * treated as platform (ga=true ⇒ delegated=false at the call site), so this never strips a GA's tools. + * + * `ruleDryRun` gates the two rule dry-runs (test_analyze_rule / test_log_pattern): their backend routes are + * TenantAdminOrGlobalReader, so they are listed only for a platform role or a Tenant Admin of the token's + * tenant (client.ts canRunRuleDryRun) — an Operator or Viewer would only collect a 403. */ export function registerTools( server: McpServer, @@ -37,11 +41,12 @@ export function registerTools( ga: boolean, strictGa: boolean = ga, delegated: boolean = false, + ruleDryRun: boolean = ga, ): void { registerSessionTools(server, ga, delegated); registerSearchTools(server, knowledgeBase, eventTypeIndex, docs, ga, delegated); registerAdminTools(server, ga, strictGa, delegated); - registerRuleTools(server, ga); + registerRuleTools(server, ga, ruleDryRun); sortToolCatalog(server); } diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts index b4d6ff0a..de3b23e3 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts @@ -15,13 +15,14 @@ import type { } from '../generated/wire-types.generated.js'; /** - * Rule-authoring tools: validate a draft gather/analyze rule locally, and dry-run a - * draft analyze rule against a real session via the backend's side-effect-free - * dryrun endpoint. Registered for EVERY role — tenant admins are the primary - * audience (the backend still enforces its own authorization: the dryrun route is - * TenantAdmin / Global Admin / read-only Global Reader). + * Rule-authoring tools: validate a draft gather/analyze rule locally (every role), and + * dry-run a draft against the backend's side-effect-free test endpoints. The dry-runs are + * registered only when `ruleDryRun` is set — their routes are TenantAdmin / Global Admin / + * read-only Global Reader, so any other caller would only collect a 403 (see + * client.ts canRunRuleDryRun). The texts of validate_rule name them accordingly. */ -export function registerRuleTools(server: McpServer, ga: boolean): void { +export function registerRuleTools(server: McpServer, ga: boolean, ruleDryRun: boolean = ga): void { + const dryRunNote = ruleDryRun ? '' : ' (a Tenant Admin runs it)'; // Tool: validate_rule (MCP-local, no backend call) server.registerTool( 'validate_rule', @@ -33,7 +34,7 @@ export function registerRuleTools(server: McpServer, ga: boolean): void { 'event-log allowlists with the agent\'s matching semantics) and semantic lint (unreachable confidence ' + 'threshold, non-evaluable confidenceFactor conditions, unknown event types, unanchored allow-list regex, ' + 'unresolvable {{token}} placeholders). Returns findings as error/warning/info. Fix all errors, then dry-run ' + - 'analyze rules with test_analyze_rule against a real session. Read get_resource(name="rule_authoring_guide") ' + + `analyze rules with test_analyze_rule against a real session${dryRunNote}. Read get_resource(name="rule_authoring_guide") ` + 'first when authoring from scratch.', inputSchema: { rule: z.record(z.string(), z.unknown()).describe('The draft rule JSON object (gather or analyze — detected automatically)'), @@ -51,9 +52,13 @@ export function registerRuleTools(server: McpServer, ga: boolean): void { findings: result.findings, nextStep: result.valid ? (result.ruleType === 'analyze' - ? 'No errors. Dry-run it now: test_analyze_rule(sessionId=, rule=) — once against a session where it SHOULD fire, once where it should not.' + ? (ruleDryRun + ? 'No errors. Dry-run it now: test_analyze_rule(sessionId=, rule=) — once against a session where it SHOULD fire, once where it should not.' + : 'No errors. The dry-run (test_analyze_rule) needs the Tenant Admin role: hand this draft to a Tenant Admin, who can dry-run it in their AI client or under Rules → Analyze Rules (Test against a session) and create it there.') : (args.rule as Record).collectorType === 'logparser' - ? 'No errors. Now test the regex with the agent\'s exact .NET engine: test_log_pattern(pattern=, sampleLines=, format=) — then deploy to a test device.' + ? (ruleDryRun + ? 'No errors. Now test the regex with the agent\'s exact .NET engine: test_log_pattern(pattern=, sampleLines=, format=) — then deploy to a test device.' + : 'No errors. Testing the regex with the agent\'s exact .NET engine (test_log_pattern) needs the Tenant Admin role: hand this draft and sample log lines to a Tenant Admin.') : 'No errors. Gather rules run on-device and cannot be dry-run server-side — deploy to a test device and check the emitted events (the gather debug log setting shows per-rule detail).') : 'Fix the error-level findings and validate again.', }, @@ -66,7 +71,7 @@ export function registerRuleTools(server: McpServer, ga: boolean): void { ); // Tool: test_log_pattern (backend .NET-regex test for logparser gather rules) - server.registerTool( + if (ruleDryRun) server.registerTool( 'test_log_pattern', { title: 'Test Logparser Pattern Against Sample Lines', @@ -102,7 +107,7 @@ export function registerRuleTools(server: McpServer, ga: boolean): void { ); // Tool: test_analyze_rule (backend dry-run, side-effect free) - server.registerTool( + if (ruleDryRun) server.registerTool( 'test_analyze_rule', { title: 'Test Analyze Rule Against Session (Dry-Run)', diff --git a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs index edc91342..23b8a99c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs @@ -194,6 +194,13 @@ namespace AutopilotMonitor.Shared.Models /// "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. public string? DelegatedRole { get; set; } + + /// + /// "Admin" | "Operator" | "Viewer": the caller's member role in the tenant the token was issued for + /// (client-capped like every tenant endpoint); omitted without a role and for platform roles. The MCP + /// server registers tools whose backend route needs a role (the rule dry-runs) only for callers holding it. + /// + public string? TenantRole { get; set; } } /// diff --git a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json index 6e3aac90..a1054c87 100644 --- a/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/lib/generated/shared-manifests.json @@ -5567,6 +5567,15 @@ }, "optional": true, "doc": "\"DelegatedAdmin\" | \"DelegatedReader\"; omitted without delegated scope." + }, + { + "name": "tenantRole", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "\"Admin\" | \"Operator\" | \"Viewer\": the caller's member role in the tenant the token was issued for (client-capped like every tenant endpoint); omitted without a role and for platform roles. The MCP server registers tools whose backend route needs a role (the rule dry-runs) only for callers holding it." } ] }, diff --git a/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts b/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts index f3e9d785..294ef1c4 100644 --- a/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts @@ -1043,6 +1043,8 @@ export interface CheckMcpAccessResponse { delegatedTenantIds?: string[]; /** "DelegatedAdmin" | "DelegatedReader"; omitted without delegated scope. */ delegatedRole?: string; + /** "Admin" | "Operator" | "Viewer": the caller's member role in the tenant the token was issued for (client-capped like every tenant endpoint); omitted without a role and for platform roles. The MCP server registers tools whose backend route needs a role (the rule dry-runs) only for callers holding it. */ + tenantRole?: string; } /** Configuration for optional agent collectors */