mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
fix(mcp): restore home-tenant access for delegated (MSP) admins
A delegated admin who is also a member/operator of their own home tenant
lost MCP access to it: any non-empty delegated scope flipped the whole
session to delegated cross-tenant routing, and enforceDelegatedTenant
rejected every tenant outside the managed set — which never contains the
home tenant (GetScopeAsync uses the home tid only as the Enterprise
entitlement gate, never adds it to the scope).
Fix is MCP-only (no backend change; the backend already authorizes the
home tenant via MemberRead and bounds the /api/global/* drill away from
it, so the home tenant must route to the JWT-bound member path):
- access-guard.ts: thread homeTenantId (JWT tid, lowercased) into the
caller context.
- client.ts: getHomeTenantId(); enforceDelegatedTenant accepts
managed union {home}; enforceDelegatedTenantForPage resolves a
non-global tenant-path nextLink (no embedded tenantId) to home so
page-2 re-selects the member path; pickGlobalOrTenantPath gains an
effectiveTenantId arg and routes delegated+home to the tenant path.
- 16 pattern-A tool call sites pass the effective tenantId (not
devices/blocked, which is platform-only).
- shared.ts / index.ts: descriptions + instructions surface the home
tenant as an addressable target.
The home tenant is accepted optimistically at the MCP boundary; the
backend MemberRead stays authoritative (a non-member is denied on the
member path, so no leak). Continuation defense-in-depth preserved.
Verify: tsc clean; vitest 352 passed (+7 home-tenant cases).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
hasCrossTenantRouting,
|
||||
isDelegated,
|
||||
getDelegatedTenantIds,
|
||||
getHomeTenantId,
|
||||
enforceDelegatedTenant,
|
||||
enforceDelegatedTenantForPage,
|
||||
getCurrentToken,
|
||||
@@ -152,6 +153,72 @@ describe('delegated (MSP) routing + tenant enforcement', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delegated (MSP) home-tenant access — member of own tenant + delegated elsewhere', () => {
|
||||
const MANAGED = 'aaaa-1111';
|
||||
const HOME = 'home-9999';
|
||||
|
||||
it('exposes the home tenant via getHomeTenantId (lowercased at the guard)', () => {
|
||||
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
|
||||
expect(getHomeTenantId()).toBe(HOME);
|
||||
expect(getDelegatedTenantIds()).toEqual([MANAGED]); // home is NOT part of the managed set
|
||||
});
|
||||
});
|
||||
|
||||
it('enforceDelegatedTenant accepts the home tenant in addition to the managed set', () => {
|
||||
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
|
||||
expect(enforceDelegatedTenant(HOME)).toBe(HOME);
|
||||
expect(enforceDelegatedTenant('HOME-9999')).toBe(HOME); // case-insensitive
|
||||
expect(enforceDelegatedTenant(MANAGED)).toBe(MANAGED); // managed still works
|
||||
});
|
||||
});
|
||||
|
||||
it('routes a delegated caller reading their HOME tenant to the tenant (member) path', () => {
|
||||
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
|
||||
// Home tenant → member path (the /api/global/* drill is bounded to the managed set → empty).
|
||||
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, HOME)).toBe(TENANT);
|
||||
// Managed tenant → cross-tenant global path.
|
||||
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, MANAGED)).toBe(GLOBAL);
|
||||
// No effective tenant passed → default cross-tenant routing (unchanged).
|
||||
expect(pickGlobalOrTenantPath(GLOBAL, TENANT)).toBe(GLOBAL);
|
||||
});
|
||||
});
|
||||
|
||||
it('still throws for a tenant that is neither managed nor the home tenant', () => {
|
||||
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
|
||||
expect(() => enforceDelegatedTenant('cccc-3333')).toThrow(/Not authorized for tenant/);
|
||||
});
|
||||
});
|
||||
|
||||
it('paginates a home-tenant search: a non-global tenant-path nextLink resolves to home (re-selects the tenant path)', () => {
|
||||
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
|
||||
// Page 2 of a home-tenant search: the backend nextLink is JWT-bound (no tenantId), path is /api/* not /api/global/*.
|
||||
const homeNextLink = '/api/search/sessions?continuation=abc&pageSize=200';
|
||||
const resolved = enforceDelegatedTenantForPage(undefined, homeNextLink);
|
||||
expect(resolved).toBe(HOME);
|
||||
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, resolved)).toBe(TENANT);
|
||||
// A managed-tenant page-2 (global nextLink carrying tenantId) still routes cross-tenant.
|
||||
const managedNextLink = `/api/global/search/sessions?continuation=abc&tenantId=${MANAGED}`;
|
||||
const resolvedManaged = enforceDelegatedTenantForPage(undefined, managedNextLink);
|
||||
expect(resolvedManaged).toBe(MANAGED);
|
||||
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, resolvedManaged)).toBe(GLOBAL);
|
||||
});
|
||||
});
|
||||
|
||||
it('a hand-crafted non-global continuation embedding a foreign tenantId is still rejected (defense in depth)', () => {
|
||||
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED], homeTenantId: HOME }, () => {
|
||||
expect(() => enforceDelegatedTenantForPage(undefined, '/api/raw/events?tenantId=cccc-3333')).toThrow(/Not authorized/);
|
||||
});
|
||||
});
|
||||
|
||||
it('without a home tenant in context, the old strict behavior holds (no phantom home access)', () => {
|
||||
runWithCaller({ token: 'msp', isGlobalAdmin: false, delegatedTenantIds: [MANAGED] }, () => {
|
||||
expect(getHomeTenantId()).toBeUndefined();
|
||||
expect(() => enforceDelegatedTenant(HOME)).toThrow(/Not authorized for tenant/);
|
||||
expect(pickGlobalOrTenantPath(GLOBAL, TENANT, HOME)).toBe(GLOBAL); // no home carve-out
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('runWithCaller — context isolation', () => {
|
||||
it('keeps concurrent contexts from bleeding into each other', async () => {
|
||||
// Interleave two async callers on the event loop; each must continue to see
|
||||
|
||||
@@ -492,6 +492,10 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
|
||||
isGlobalReader: result.isGlobalReader,
|
||||
delegatedTenantIds: result.delegatedTenantIds,
|
||||
delegatedRole: result.delegatedRole,
|
||||
// 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.
|
||||
homeTenantId: claims.tid?.toLowerCase(),
|
||||
},
|
||||
() => next(),
|
||||
);
|
||||
|
||||
@@ -58,6 +58,17 @@ interface CallerContext {
|
||||
delegatedTenantIds?: string[];
|
||||
/** Strongest delegated role ("DelegatedAdmin" | "DelegatedReader"); informational (read-only server). */
|
||||
delegatedRole?: 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
|
||||
* a member/operator of their OWN home tenant must keep reading it over MCP. That access is member-based
|
||||
* (JWT-bound `/api/*`), NOT a delegated grant — the home tenant is not in `delegatedTenantIds` and the
|
||||
* `/api/global/*` drill is bounded to the managed set (would return empty for it). So the home tenant is
|
||||
* accepted by `enforceDelegatedTenant` and routed to the tenant-scoped member path by
|
||||
* `pickGlobalOrTenantPath`. The backend is authoritative: a delegated caller who is NOT a member of their
|
||||
* home tenant is simply denied there (MemberRead), so accepting the id here never leaks.
|
||||
*/
|
||||
homeTenantId?: string;
|
||||
}
|
||||
|
||||
const callerStore = new AsyncLocalStorage<CallerContext>();
|
||||
@@ -114,6 +125,15 @@ export function isDelegated(): boolean {
|
||||
return (callerStore.getStore()?.delegatedTenantIds?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current caller's home tenant (lowercase JWT `tid`), or undefined when no context is active / the
|
||||
* token carried no tid. Used by the delegated (MSP) routing helpers so a delegated admin who is also a
|
||||
* member of their home tenant can still read it via the tenant-scoped member path.
|
||||
*/
|
||||
export function getHomeTenantId(): string | undefined {
|
||||
return callerStore.getStore()?.homeTenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the caller may address the cross-tenant `/api/global/*` paths. That is the union of platform
|
||||
* scope (GA / read-only Global Reader, who may omit tenantId for an aggregate) and delegated scope (MSP,
|
||||
@@ -130,8 +150,21 @@ export function hasCrossTenantRouting(): boolean {
|
||||
* variant. The same `tenantId` query param is meaningful in both worlds — on `/api/global/*` it filters
|
||||
* (and for a delegated caller the backend rescue bounds it to the managed set); on `/api/*` it's
|
||||
* informational (backend resolves from JWT).
|
||||
*
|
||||
* `effectiveTenantId` (the already-`enforceDelegatedTenant`-resolved target) carves out ONE exception: a
|
||||
* delegated (MSP) caller reading their OWN home tenant is routed to the tenant-scoped MEMBER path, because
|
||||
* their authorization there is member/operator (JWT-bound `/api/*`), NOT a delegated grant — and the
|
||||
* `/api/global/*` drill is bounded to the managed set, so it would return an EMPTY page for the home
|
||||
* tenant. Omitting the arg preserves the old behavior for every non-home / non-delegated call.
|
||||
*/
|
||||
export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string): string {
|
||||
export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string, effectiveTenantId?: string): string {
|
||||
if (
|
||||
isDelegated() &&
|
||||
effectiveTenantId &&
|
||||
effectiveTenantId.toLowerCase() === getHomeTenantId()
|
||||
) {
|
||||
return tenantPath;
|
||||
}
|
||||
return hasCrossTenantRouting() ? globalPath : tenantPath;
|
||||
}
|
||||
|
||||
@@ -147,16 +180,23 @@ export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string):
|
||||
export function enforceDelegatedTenant(tenantId?: string): string | undefined {
|
||||
if (!isDelegated()) return tenantId;
|
||||
const allowed = getDelegatedTenantIds()!; // non-empty when isDelegated()
|
||||
// The caller's own home tenant is an ADDITIONAL valid target: a delegated admin who is also a member of
|
||||
// their home tenant reads it via the member path (see pickGlobalOrTenantPath). It is deliberately NOT in
|
||||
// the managed set (that is other tenants they administer). Accepting it here is safe even for a delegated
|
||||
// caller who is NOT a member — the backend's MemberRead denies them on the tenant path, so no leak.
|
||||
const home = getHomeTenantId();
|
||||
const t = (tenantId ?? '').toLowerCase();
|
||||
if (!t) {
|
||||
throw new Error(
|
||||
'tenantId is required: as a delegated (MSP) user you must name one of your managed tenants. ' +
|
||||
`Managed tenants: ${allowed.join(', ')}`,
|
||||
'tenantId is required: as a delegated (MSP) user you must name the tenant to query. ' +
|
||||
`Managed tenants: ${allowed.join(', ')}` +
|
||||
(home ? `; or your own home tenant: ${home}.` : '.'),
|
||||
);
|
||||
}
|
||||
if (!allowed.includes(t)) {
|
||||
if (!allowed.includes(t) && t !== home) {
|
||||
throw new Error(
|
||||
`Not authorized for tenant ${tenantId}. Your managed tenants: ${allowed.join(', ')}`,
|
||||
`Not authorized for tenant ${tenantId}. Your managed tenants: ${allowed.join(', ')}` +
|
||||
(home ? ` (or your own home tenant ${home}).` : '.'),
|
||||
);
|
||||
}
|
||||
return t;
|
||||
@@ -185,7 +225,17 @@ function tenantIdFromNextLink(continuation?: string): string | undefined {
|
||||
*/
|
||||
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {
|
||||
if (!isDelegated()) return tenantId;
|
||||
return enforceDelegatedTenant(tenantIdFromNextLink(continuation) ?? tenantId);
|
||||
const linkTenant = tenantIdFromNextLink(continuation);
|
||||
// A follow-up page whose continuation is a full backend nextLink to a TENANT-scoped (non-global) path
|
||||
// with NO embedded tenantId is inherently JWT-bound — it can only ever serve the caller's own home
|
||||
// tenant (page 1 already routed there via pickGlobalOrTenantPath). Resolve it TO the home tenant so the
|
||||
// follow-up page re-selects the same tenant path (matching page 1) without a managed-tenant check. A
|
||||
// hand-crafted continuation that DOES embed a tenantId still flows through enforceDelegatedTenant below,
|
||||
// so the defense-in-depth validation is preserved.
|
||||
if (continuation?.startsWith('/api/') && !continuation.startsWith('/api/global/') && !linkTenant) {
|
||||
return getHomeTenantId() ?? tenantId;
|
||||
}
|
||||
return enforceDelegatedTenant(linkTenant ?? tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,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 { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds } from './client.js';
|
||||
import { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds, getHomeTenantId } from './client.js';
|
||||
import { API_BASE_URL } from './config.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -137,15 +137,17 @@ console.error(`Event-type index ready: ${eventTypeIndex.name} — ${eventTypeInd
|
||||
// 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, delegated: boolean, managedTenants: string[]): string {
|
||||
function buildInstructions(ga: boolean, delegated: boolean, managedTenants: string[], homeTenantId?: string): string {
|
||||
// Delegated (MSP) callers get a tenant-bounded surface: cross-tenant ROUTING, but every query MUST name
|
||||
// one of their managed tenants — no platform aggregate. Spell that out once here (the host surfaces it
|
||||
// per connection) so the model passes tenantId up front instead of discovering it via a tool error.
|
||||
// a tenant — no platform aggregate. Spell that out once here (the host surfaces it per connection) so the
|
||||
// model passes tenantId up front instead of discovering it via a tool error. A delegated admin who is
|
||||
// also a member of their own home tenant may name it too (routed to the member path), so surface it.
|
||||
const scopeLine = ga
|
||||
? 'Scope: omit tenantId for cross-tenant queries (platform scope); pass tenantId to scope to one tenant.'
|
||||
: delegated
|
||||
? 'Scope: you are a delegated (MSP) administrator. Every query MUST name one of your managed tenants via ' +
|
||||
`tenantId — there is no cross-tenant aggregate. Your managed tenants: ${managedTenants.join(', ')}.`
|
||||
? 'Scope: you are a delegated (MSP) administrator. Every query MUST name a tenant via tenantId — there ' +
|
||||
`is no cross-tenant aggregate. Your managed tenants: ${managedTenants.join(', ')}.` +
|
||||
(homeTenantId ? ` If you are a member of your own home tenant (${homeTenantId}), you may query it by naming it too.` : '')
|
||||
: 'Scope: all queries are automatically limited to your tenant.';
|
||||
return [
|
||||
'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.',
|
||||
@@ -165,10 +167,10 @@ function buildInstructions(ga: boolean, delegated: boolean, managedTenants: stri
|
||||
* 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, strictGa: boolean, delegated: boolean, managedTenants: string[]): McpServer {
|
||||
function createMcpServer(ga: boolean, strictGa: boolean, delegated: boolean, managedTenants: string[], homeTenantId?: string): McpServer {
|
||||
const s = new McpServer(
|
||||
{ name: 'Autopilot-Monitor', version: SERVER_VERSION },
|
||||
{ instructions: buildInstructions(ga, delegated, managedTenants) },
|
||||
{ instructions: buildInstructions(ga, delegated, managedTenants, homeTenantId) },
|
||||
);
|
||||
registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa, delegated);
|
||||
registerResources(s);
|
||||
@@ -277,7 +279,10 @@ app.all('/mcp', async (req, res) => {
|
||||
const ga = hasGlobalScope();
|
||||
const delegated = !ga && isDelegated();
|
||||
const managedTenants = delegated ? (getDelegatedTenantIds() ?? []) : [];
|
||||
const server = createMcpServer(ga, isGlobalAdmin(), delegated, managedTenants);
|
||||
// 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;
|
||||
const server = createMcpServer(ga, isGlobalAdmin(), delegated, managedTenants, homeTenantId);
|
||||
|
||||
// Guarantee cleanup once the response is done, even on client disconnect.
|
||||
res.on('close', () => {
|
||||
|
||||
@@ -244,7 +244,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
const params: Record<string, string | number | undefined> = { ...rest };
|
||||
if (tenantId) params.tenantId = tenantId;
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics');
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
|
||||
const data = await apiFetch(`${prefix}/geographic${buildQuery(params)}`);
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
@@ -294,7 +294,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
...buildGeoLocationParams({ locationKey, country, region, city }),
|
||||
};
|
||||
if (tenantId) params.tenantId = tenantId;
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics');
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
|
||||
const data = await apiFetch(`${prefix}/geographic/sessions${buildQuery(params)}`) as
|
||||
{ success?: boolean; sessions?: unknown[]; totalCount?: number };
|
||||
|
||||
@@ -448,7 +448,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
// GA → /api/global/metrics/usage (tenantId is a filter); Tenant-Admin → /api/metrics/usage
|
||||
// (JWT-scoped; tenantId ignored). Routing by role unlocks the MemberRead tenant endpoint
|
||||
// for non-GA callers instead of a blanket 403.
|
||||
const path = pickGlobalOrTenantPath('/api/global/metrics/usage', '/api/metrics/usage');
|
||||
const path = pickGlobalOrTenantPath('/api/global/metrics/usage', '/api/metrics/usage', tenantId);
|
||||
const data = await apiFetch(`${path}${buildQuery({ tenantId, days })}`);
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
@@ -540,7 +540,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
try {
|
||||
const { tenantId: rawTenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize, continuation } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/audit/logs', '/api/audit/logs');
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/audit/logs', '/api/audit/logs', tenantId);
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
{ tenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize },
|
||||
@@ -686,7 +686,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
const { tenantId: rawTenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, fields, pageSize, continuation } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
if (eventType) assertKnownEventType(eventType);
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events');
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events', tenantId);
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
{ tenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, fields, pageSize },
|
||||
@@ -755,7 +755,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild,
|
||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize, continuation } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/raw/sessions', '/api/raw/sessions');
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/raw/sessions', '/api/raw/sessions', tenantId);
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
{ tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix,
|
||||
@@ -917,7 +917,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
ruleType: args.ruleType,
|
||||
};
|
||||
if (tenantId) params.tenantId = tenantId;
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics');
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
|
||||
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`);
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
@@ -953,7 +953,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
try {
|
||||
const { tenantId: rawTenantId, days, topN } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics/vulnerability', '/api/metrics/vulnerability');
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics/vulnerability', '/api/metrics/vulnerability', tenantId);
|
||||
const data = await apiFetch(`${prefix}${buildQuery({ tenantId, days, topN })}`);
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
@@ -995,7 +995,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
// GA → /api/global/metrics/app (tenantId is a filter); Tenant-Admin → /api/metrics/app
|
||||
// (JWT-scoped; tenantId ignored).
|
||||
const path = pickGlobalOrTenantPath('/api/global/metrics/app', '/api/metrics/app');
|
||||
const path = pickGlobalOrTenantPath('/api/global/metrics/app', '/api/metrics/app', tenantId);
|
||||
const data = await apiFetch(`${path}${buildQuery({ tenantId, days })}`);
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
@@ -1093,7 +1093,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
));
|
||||
}
|
||||
// Endpoint returns the whole inventory in one shot, so page it client-side.
|
||||
const path = pickGlobalOrTenantPath('/api/vulnerability/software-inventory', '/api/metrics/software-inventory');
|
||||
const path = pickGlobalOrTenantPath('/api/vulnerability/software-inventory', '/api/metrics/software-inventory', tenantId);
|
||||
const data = await apiFetch(`${path}${buildQuery({ tenantId })}`);
|
||||
return toolResultText(paginateInventory(data, pageSize, continuation), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -426,7 +426,7 @@ async function fetchSessionEvents(
|
||||
// is reserved for the genuine "no keyword maps to any event type" case below.)
|
||||
const candidates = ordered.slice(0, MAX_EVENT_TYPE_CANDIDATES);
|
||||
const candidatesDropped = ordered.length > candidates.length;
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events');
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events', tenantId);
|
||||
const { events, truncated, anySucceeded } = await fetchEventsViaIndex(candidates, basePath, tenantId, budget);
|
||||
if (anySucceeded) {
|
||||
// No event appears in more than one single-type walk, so the only
|
||||
@@ -444,7 +444,7 @@ async function fetchSessionEvents(
|
||||
const searchParams: Record<string, string | number | undefined> = { status: 'Failed', pageSize: LEGACY_FALLBACK_SESSION_CAP };
|
||||
if (tenantId) searchParams.tenantId = tenantId;
|
||||
const searchQ = buildQuery(searchParams);
|
||||
const searchBase = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions');
|
||||
const searchBase = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions', tenantId);
|
||||
const sessions = await apiFetch(`${searchBase}${searchQ}`) as {
|
||||
sessions?: Array<{ sessionId?: string }>;
|
||||
};
|
||||
|
||||
@@ -144,7 +144,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
// continuation nextLink. No-op for GA/Reader/tenant users.
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
// GA → /api/global/search/sessions (tenantId is filter); Tenant-Admin → /api/search/sessions (JWT-bound).
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions');
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions', tenantId);
|
||||
// followNextLink handles full nextLink paths verbatim. For first-page calls
|
||||
// we still need to layer in deviceProperties as `prop.<key>` query params,
|
||||
// which followNextLink doesn't know about — so build the param record and
|
||||
@@ -202,7 +202,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
// so an empty page never carries a nextLink — no auto-exhaust needed. But validate
|
||||
// the type so a typo is a clear error rather than a silent empty result.
|
||||
assertKnownEventType(eventType);
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-event', '/api/search/sessions-by-event');
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-event', '/api/search/sessions-by-event', tenantId);
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
{ eventType, tenantId, pageSize },
|
||||
@@ -620,7 +620,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const params: Record<string, string | number | undefined> = { ...rest };
|
||||
if (tenantId) params.tenantId = tenantId;
|
||||
const q = buildQuery(params);
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics');
|
||||
const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics', tenantId);
|
||||
const [summaryRes, appsRes] = await Promise.allSettled([
|
||||
apiFetch(`${prefix}/summary${q}`),
|
||||
apiFetch(`${prefix}/app${q}`),
|
||||
@@ -684,7 +684,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
// Normalize to canonical upper-case form (schema accepts case-insensitive).
|
||||
const normalizedCve = cveId.toUpperCase();
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-cve', '/api/search/sessions-by-cve');
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-cve', '/api/search/sessions-by-cve', tenantId);
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
{ cveId: normalizedCve, tenantId, minCvssScore, overallRisk, pageSize },
|
||||
|
||||
@@ -58,8 +58,9 @@ export function tenantIdDescription(ga: boolean, delegated: boolean, gaText: str
|
||||
// pages behave differently — backend-nextLink pagers re-send tenantId inside the continuation, but
|
||||
// offset-based client-side pagers (geo-offset:/inv-offset:) still need it re-passed every page. The
|
||||
// per-tool `continuation` arg description owns those mechanics; here we only state the invariant.
|
||||
return 'REQUIRED: name one of YOUR managed tenants (delegated/MSP). There is no cross-tenant aggregate ' +
|
||||
'and no home-tenant default — every query must target a managed tenant.';
|
||||
return 'REQUIRED: name the tenant to query — one of YOUR managed tenants (delegated/MSP), or your own ' +
|
||||
'home tenant if you are a member of it. There is no cross-tenant aggregate and no implicit default — ' +
|
||||
'every query must name a specific tenant.';
|
||||
}
|
||||
return ga ? gaText : tenantText;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user