diff --git a/google-analytics/server/lib/env.ts b/google-analytics/server/lib/env.ts index a9d6c417..8e331ee5 100644 --- a/google-analytics/server/lib/env.ts +++ b/google-analytics/server/lib/env.ts @@ -10,11 +10,30 @@ export const getGoogleAccessToken = (env: Env): string => { return authorization.replace(/^Bearer\s+/i, ""); }; +// Normalizes "1234567" → "properties/1234567"; already-prefixed IDs pass through. +function normalizePropertyId(id: string): string { + const clean = String(id).trim(); + return /^\d+$/.test(clean) ? `properties/${clean}` : clean; +} + +/** + * Returns the normalized allowlist of property IDs for this installation, + * or null if no allowlist is configured (meaning all properties are allowed). + */ +export const getAllowedPropertyIds = (env: Env): string[] | null => { + const ids = env.MESH_REQUEST_CONTEXT?.state?.allowedPropertyIds; + if (!ids || ids.length === 0) return null; + return ids.map(normalizePropertyId); +}; + /** * Resolves the GA4 property to use for a request. * * Prefers the `property` passed to the tool; when omitted, falls back to the * `propertyId` configured on the MCP installation state. + * + * When allowedPropertyIds is configured, the resolved property is validated + * against the allowlist — any property not in the list is rejected. */ export const resolveProperty = (env: Env, property?: string | null): string => { const resolved = property ?? env.MESH_REQUEST_CONTEXT?.state?.propertyId; @@ -23,5 +42,12 @@ export const resolveProperty = (env: Env, property?: string | null): string => { "No GA4 property provided. Pass a `property` argument or configure a default `propertyId` in this integration's settings.", ); } - return resolved; + const normalized = normalizePropertyId(resolved); + const allowed = getAllowedPropertyIds(env); + if (allowed && !allowed.includes(normalized)) { + throw new Error( + `Access denied: property '${normalized}' is not in the allowlist configured for this integration.`, + ); + } + return normalized; }; diff --git a/google-analytics/server/tools/accounts.ts b/google-analytics/server/tools/accounts.ts index 7b0650b3..ecf1044d 100644 --- a/google-analytics/server/tools/accounts.ts +++ b/google-analytics/server/tools/accounts.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { createPrivateTool } from "@decocms/runtime/tools"; import type { Env } from "../../shared/deco.gen.ts"; import { GaClient } from "../lib/ga-client.ts"; +import { getAllowedPropertyIds } from "../lib/env.ts"; import { AccountSummariesResponseSchema } from "../lib/schemas.ts"; export const getAccountSummariesTool = (env: Env) => @@ -15,7 +16,36 @@ export const getAccountSummariesTool = (env: Env) => const client = GaClient.fromEnv(env); try { - const result = await client.listAccountSummaries(); + const result = (await client.listAccountSummaries()) as { + accountSummaries?: Array<{ + name: string; + account?: string; + displayName?: string; + propertySummaries?: Array<{ + property: string; + displayName: string; + propertyType?: string; + parent?: string; + }>; + }>; + }; + const allowed = getAllowedPropertyIds(env); + + if (allowed) { + const filtered = (result.accountSummaries ?? []) + .map((account) => ({ + ...account, + propertySummaries: (account.propertySummaries ?? []).filter((p) => + allowed.includes(p.property), + ), + })) + .filter((account) => account.propertySummaries.length > 0); + + return AccountSummariesResponseSchema.parse({ + response: { accountSummaries: filtered }, + }); + } + return AccountSummariesResponseSchema.parse({ response: result }); } catch (error) { throw new Error( diff --git a/google-analytics/shared/deco.gen.ts b/google-analytics/shared/deco.gen.ts index 841faef1..abf4c4aa 100644 --- a/google-analytics/shared/deco.gen.ts +++ b/google-analytics/shared/deco.gen.ts @@ -7,6 +7,12 @@ export const StateSchema = z.object({ .describe( "Default GA4 Property identifier — 'properties/1234567' or just '1234567'. Used as a fallback for tools when their `property` argument is omitted.", ), + allowedPropertyIds: z + .array(z.string()) + .nullish() + .describe( + "Optional allowlist of GA4 property IDs this installation may access. Accepts 'properties/1234567' or just '1234567'. When set, get-account-summaries only returns matching properties (and the accounts that contain them), and all other tools reject any property not in the list. Leave empty to allow all properties the authenticated user can access.", + ), }); export interface MeshRequestContext {