mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +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,
|
hasCrossTenantRouting,
|
||||||
isDelegated,
|
isDelegated,
|
||||||
getDelegatedTenantIds,
|
getDelegatedTenantIds,
|
||||||
|
getHomeTenantId,
|
||||||
enforceDelegatedTenant,
|
enforceDelegatedTenant,
|
||||||
enforceDelegatedTenantForPage,
|
enforceDelegatedTenantForPage,
|
||||||
getCurrentToken,
|
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', () => {
|
describe('runWithCaller — context isolation', () => {
|
||||||
it('keeps concurrent contexts from bleeding into each other', async () => {
|
it('keeps concurrent contexts from bleeding into each other', async () => {
|
||||||
// Interleave two async callers on the event loop; each must continue to see
|
// 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,
|
isGlobalReader: result.isGlobalReader,
|
||||||
delegatedTenantIds: result.delegatedTenantIds,
|
delegatedTenantIds: result.delegatedTenantIds,
|
||||||
delegatedRole: result.delegatedRole,
|
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(),
|
() => next(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ interface CallerContext {
|
|||||||
delegatedTenantIds?: string[];
|
delegatedTenantIds?: string[];
|
||||||
/** Strongest delegated role ("DelegatedAdmin" | "DelegatedReader"); informational (read-only server). */
|
/** Strongest delegated role ("DelegatedAdmin" | "DelegatedReader"); informational (read-only server). */
|
||||||
delegatedRole?: string;
|
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>();
|
const callerStore = new AsyncLocalStorage<CallerContext>();
|
||||||
@@ -114,6 +125,15 @@ export function isDelegated(): boolean {
|
|||||||
return (callerStore.getStore()?.delegatedTenantIds?.length ?? 0) > 0;
|
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
|
* 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,
|
* 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
|
* 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
|
* (and for a delegated caller the backend rescue bounds it to the managed set); on `/api/*` it's
|
||||||
* informational (backend resolves from JWT).
|
* 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;
|
return hasCrossTenantRouting() ? globalPath : tenantPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,16 +180,23 @@ export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string):
|
|||||||
export function enforceDelegatedTenant(tenantId?: string): string | undefined {
|
export function enforceDelegatedTenant(tenantId?: string): string | undefined {
|
||||||
if (!isDelegated()) return tenantId;
|
if (!isDelegated()) return tenantId;
|
||||||
const allowed = getDelegatedTenantIds()!; // non-empty when isDelegated()
|
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();
|
const t = (tenantId ?? '').toLowerCase();
|
||||||
if (!t) {
|
if (!t) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'tenantId is required: as a delegated (MSP) user you must name one of your managed tenants. ' +
|
'tenantId is required: as a delegated (MSP) user you must name the tenant to query. ' +
|
||||||
`Managed tenants: ${allowed.join(', ')}`,
|
`Managed tenants: ${allowed.join(', ')}` +
|
||||||
|
(home ? `; or your own home tenant: ${home}.` : '.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!allowed.includes(t)) {
|
if (!allowed.includes(t) && t !== home) {
|
||||||
throw new Error(
|
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;
|
return t;
|
||||||
@@ -185,7 +225,17 @@ function tenantIdFromNextLink(continuation?: string): string | undefined {
|
|||||||
*/
|
*/
|
||||||
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {
|
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {
|
||||||
if (!isDelegated()) return tenantId;
|
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 { buildEventTypeSearchDocs } from './resource-catalog.js';
|
||||||
import { createOAuthRouter } from './oauth.js';
|
import { createOAuthRouter } from './oauth.js';
|
||||||
import { accessGuard } from './access-guard.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';
|
import { API_BASE_URL } from './config.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
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
|
// Reader) sees the cross-tenant scope hint. A normal tenant user gets
|
||||||
// instructions with no mention of cross-tenant capability at all — the surface
|
// instructions with no mention of cross-tenant capability at all — the surface
|
||||||
// is scoped to what they can actually do.
|
// 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
|
// 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
|
// a tenant — no platform aggregate. Spell that out once here (the host surfaces it per connection) so the
|
||||||
// per connection) so the model passes tenantId up front instead of discovering it via a tool error.
|
// 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
|
const scopeLine = ga
|
||||||
? 'Scope: omit tenantId for cross-tenant queries (platform scope); pass tenantId to scope to one tenant.'
|
? 'Scope: omit tenantId for cross-tenant queries (platform scope); pass tenantId to scope to one tenant.'
|
||||||
: delegated
|
: delegated
|
||||||
? 'Scope: you are a delegated (MSP) administrator. Every query MUST name one of your managed tenants via ' +
|
? 'Scope: you are a delegated (MSP) administrator. Every query MUST name a tenant via tenantId — there ' +
|
||||||
`tenantId — there is no cross-tenant aggregate. Your managed tenants: ${managedTenants.join(', ')}.`
|
`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.';
|
: 'Scope: all queries are automatically limited to your tenant.';
|
||||||
return [
|
return [
|
||||||
'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.',
|
'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
|
* role: a non-Global-Admin never sees GA-only tools or any cross-tenant / GA
|
||||||
* wording — reducing both confusion and attack surface.
|
* 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(
|
const s = new McpServer(
|
||||||
{ name: 'Autopilot-Monitor', version: SERVER_VERSION },
|
{ name: 'Autopilot-Monitor', version: SERVER_VERSION },
|
||||||
{ instructions: buildInstructions(ga, delegated, managedTenants) },
|
{ instructions: buildInstructions(ga, delegated, managedTenants, homeTenantId) },
|
||||||
);
|
);
|
||||||
registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa, delegated);
|
registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa, delegated);
|
||||||
registerResources(s);
|
registerResources(s);
|
||||||
@@ -277,7 +279,10 @@ app.all('/mcp', async (req, res) => {
|
|||||||
const ga = hasGlobalScope();
|
const ga = hasGlobalScope();
|
||||||
const delegated = !ga && isDelegated();
|
const delegated = !ga && isDelegated();
|
||||||
const managedTenants = delegated ? (getDelegatedTenantIds() ?? []) : [];
|
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.
|
// Guarantee cleanup once the response is done, even on client disconnect.
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
|||||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||||
const params: Record<string, string | number | undefined> = { ...rest };
|
const params: Record<string, string | number | undefined> = { ...rest };
|
||||||
if (tenantId) params.tenantId = tenantId;
|
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)}`);
|
const data = await apiFetch(`${prefix}/geographic${buildQuery(params)}`);
|
||||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -294,7 +294,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
|||||||
...buildGeoLocationParams({ locationKey, country, region, city }),
|
...buildGeoLocationParams({ locationKey, country, region, city }),
|
||||||
};
|
};
|
||||||
if (tenantId) params.tenantId = tenantId;
|
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
|
const data = await apiFetch(`${prefix}/geographic/sessions${buildQuery(params)}`) as
|
||||||
{ success?: boolean; sessions?: unknown[]; totalCount?: number };
|
{ 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
|
// 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
|
// (JWT-scoped; tenantId ignored). Routing by role unlocks the MemberRead tenant endpoint
|
||||||
// for non-GA callers instead of a blanket 403.
|
// 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 })}`);
|
const data = await apiFetch(`${path}${buildQuery({ tenantId, days })}`);
|
||||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -540,7 +540,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
|||||||
try {
|
try {
|
||||||
const { tenantId: rawTenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize, continuation } = args;
|
const { tenantId: rawTenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize, continuation } = args;
|
||||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
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(
|
const path = followNextLink(
|
||||||
basePath,
|
basePath,
|
||||||
{ tenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize },
|
{ 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: rawTenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, fields, pageSize, continuation } = args;
|
||||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||||
if (eventType) assertKnownEventType(eventType);
|
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(
|
const path = followNextLink(
|
||||||
basePath,
|
basePath,
|
||||||
{ tenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, fields, pageSize },
|
{ 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,
|
imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild,
|
||||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize, continuation } = args;
|
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, fields, pageSize, continuation } = args;
|
||||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
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(
|
const path = followNextLink(
|
||||||
basePath,
|
basePath,
|
||||||
{ tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix,
|
{ tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix,
|
||||||
@@ -917,7 +917,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
|||||||
ruleType: args.ruleType,
|
ruleType: args.ruleType,
|
||||||
};
|
};
|
||||||
if (tenantId) params.tenantId = tenantId;
|
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)}`);
|
const data = await apiFetch(`${prefix}/rule-stats${buildQuery(params)}`);
|
||||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -953,7 +953,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
|||||||
try {
|
try {
|
||||||
const { tenantId: rawTenantId, days, topN } = args;
|
const { tenantId: rawTenantId, days, topN } = args;
|
||||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
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 })}`);
|
const data = await apiFetch(`${prefix}${buildQuery({ tenantId, days, topN })}`);
|
||||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
@@ -995,7 +995,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
|||||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||||
// GA → /api/global/metrics/app (tenantId is a filter); Tenant-Admin → /api/metrics/app
|
// GA → /api/global/metrics/app (tenantId is a filter); Tenant-Admin → /api/metrics/app
|
||||||
// (JWT-scoped; tenantId ignored).
|
// (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 })}`);
|
const data = await apiFetch(`${path}${buildQuery({ tenantId, days })}`);
|
||||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||||
} catch (error: unknown) {
|
} 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.
|
// 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 })}`);
|
const data = await apiFetch(`${path}${buildQuery({ tenantId })}`);
|
||||||
return toolResultText(paginateInventory(data, pageSize, continuation), MAX_RESULT_SIZE_CHARS.adminStream);
|
return toolResultText(paginateInventory(data, pageSize, continuation), MAX_RESULT_SIZE_CHARS.adminStream);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
@@ -426,7 +426,7 @@ async function fetchSessionEvents(
|
|||||||
// is reserved for the genuine "no keyword maps to any event type" case below.)
|
// is reserved for the genuine "no keyword maps to any event type" case below.)
|
||||||
const candidates = ordered.slice(0, MAX_EVENT_TYPE_CANDIDATES);
|
const candidates = ordered.slice(0, MAX_EVENT_TYPE_CANDIDATES);
|
||||||
const candidatesDropped = ordered.length > candidates.length;
|
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);
|
const { events, truncated, anySucceeded } = await fetchEventsViaIndex(candidates, basePath, tenantId, budget);
|
||||||
if (anySucceeded) {
|
if (anySucceeded) {
|
||||||
// No event appears in more than one single-type walk, so the only
|
// 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 };
|
const searchParams: Record<string, string | number | undefined> = { status: 'Failed', pageSize: LEGACY_FALLBACK_SESSION_CAP };
|
||||||
if (tenantId) searchParams.tenantId = tenantId;
|
if (tenantId) searchParams.tenantId = tenantId;
|
||||||
const searchQ = buildQuery(searchParams);
|
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 {
|
const sessions = await apiFetch(`${searchBase}${searchQ}`) as {
|
||||||
sessions?: Array<{ sessionId?: string }>;
|
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.
|
// continuation nextLink. No-op for GA/Reader/tenant users.
|
||||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||||
// GA → /api/global/search/sessions (tenantId is filter); Tenant-Admin → /api/search/sessions (JWT-bound).
|
// 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
|
// followNextLink handles full nextLink paths verbatim. For first-page calls
|
||||||
// we still need to layer in deviceProperties as `prop.<key>` query params,
|
// we still need to layer in deviceProperties as `prop.<key>` query params,
|
||||||
// which followNextLink doesn't know about — so build the param record and
|
// 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
|
// 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.
|
// the type so a typo is a clear error rather than a silent empty result.
|
||||||
assertKnownEventType(eventType);
|
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(
|
const path = followNextLink(
|
||||||
basePath,
|
basePath,
|
||||||
{ eventType, tenantId, pageSize },
|
{ eventType, tenantId, pageSize },
|
||||||
@@ -620,7 +620,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
|||||||
const params: Record<string, string | number | undefined> = { ...rest };
|
const params: Record<string, string | number | undefined> = { ...rest };
|
||||||
if (tenantId) params.tenantId = tenantId;
|
if (tenantId) params.tenantId = tenantId;
|
||||||
const q = buildQuery(params);
|
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([
|
const [summaryRes, appsRes] = await Promise.allSettled([
|
||||||
apiFetch(`${prefix}/summary${q}`),
|
apiFetch(`${prefix}/summary${q}`),
|
||||||
apiFetch(`${prefix}/app${q}`),
|
apiFetch(`${prefix}/app${q}`),
|
||||||
@@ -684,7 +684,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
|||||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||||
// Normalize to canonical upper-case form (schema accepts case-insensitive).
|
// Normalize to canonical upper-case form (schema accepts case-insensitive).
|
||||||
const normalizedCve = cveId.toUpperCase();
|
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(
|
const path = followNextLink(
|
||||||
basePath,
|
basePath,
|
||||||
{ cveId: normalizedCve, tenantId, minCvssScore, overallRisk, pageSize },
|
{ 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
|
// 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
|
// 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.
|
// 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 ' +
|
return 'REQUIRED: name the tenant to query — one of YOUR managed tenants (delegated/MSP), or your own ' +
|
||||||
'and no home-tenant default — every query must target a managed tenant.';
|
'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;
|
return ga ? gaText : tenantText;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user