From abef8e214aad1d99d41e9ef82f3ce5f4a4cf8c9d Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 1 Jul 2026 13:22:47 -0300 Subject: [PATCH 1/5] feat(google-analytics): add allowedPropertyIds allowlist to restrict property access Adds a new `allowedPropertyIds` field to the StateSchema that, when configured, restricts all tools to only operate on the listed GA4 properties. get-account-summaries filters out accounts/properties not in the allowlist, and resolveProperty enforces the restriction on every other tool call. Co-Authored-By: Claude Sonnet 4.6 --- google-analytics/server/lib/env.ts | 28 ++++++++++++++++++-- google-analytics/server/tools/accounts.ts | 32 ++++++++++++++++++++++- google-analytics/shared/deco.gen.ts | 6 +++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/google-analytics/server/lib/env.ts b/google-analytics/server/lib/env.ts index a9d6c417..ff78700a 100644 --- a/google-analytics/server/lib/env.ts +++ b/google-analytics/server/lib/env.ts @@ -10,11 +10,28 @@ 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. + * `propertyId` configured on the MCP installation state. If an allowlist is + * configured, the resolved property must be in it. */ export const resolveProperty = (env: Env, property?: string | null): string => { const resolved = property ?? env.MESH_REQUEST_CONTEXT?.state?.propertyId; @@ -23,5 +40,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( + `Property "${normalized}" is not allowed for this integration. Allowed properties: ${allowed.join(", ")}.`, + ); + } + return normalized; }; diff --git a/google-analytics/server/tools/accounts.ts b/google-analytics/server/tools/accounts.ts index 7b0650b3..7d72401b 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( + (prop) => allowed.includes(prop.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..fabf2fa8 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 integration may access. Accepts 'properties/1234567' or just '1234567'. When set, requests for unlisted properties are rejected and get-account-summaries only returns matching accounts/properties. Leave empty to allow all properties the authenticated user can access.", + ), }); export interface MeshRequestContext { From 68f7f088eb456dbbfcebedf8d4859cdeaf324dab Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 1 Jul 2026 13:29:15 -0300 Subject: [PATCH 2/5] refactor(google-analytics): replace allowedPropertyIds with allowedAccountIds Account-level restriction is simpler to configure (one ID per customer) and automatically includes all properties under the account, including new ones added in the future. Co-Authored-By: Claude Sonnet 4.6 --- google-analytics/server/lib/env.ts | 28 ++++++++--------------- google-analytics/server/tools/accounts.ts | 15 ++++-------- google-analytics/shared/deco.gen.ts | 4 ++-- 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/google-analytics/server/lib/env.ts b/google-analytics/server/lib/env.ts index ff78700a..ed47b345 100644 --- a/google-analytics/server/lib/env.ts +++ b/google-analytics/server/lib/env.ts @@ -10,28 +10,27 @@ 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 { +// Normalizes "1234567" → "accounts/1234567"; already-prefixed IDs pass through. +function normalizeAccountId(id: string): string { const clean = String(id).trim(); - return /^\d+$/.test(clean) ? `properties/${clean}` : clean; + return /^\d+$/.test(clean) ? `accounts/${clean}` : clean; } /** - * Returns the normalized allowlist of property IDs for this installation, - * or null if no allowlist is configured (meaning all properties are allowed). + * Returns the normalized allowlist of account IDs for this installation, + * or null if no allowlist is configured (meaning all accounts are allowed). */ -export const getAllowedPropertyIds = (env: Env): string[] | null => { - const ids = env.MESH_REQUEST_CONTEXT?.state?.allowedPropertyIds; +export const getAllowedAccountIds = (env: Env): string[] | null => { + const ids = env.MESH_REQUEST_CONTEXT?.state?.allowedAccountIds; if (!ids || ids.length === 0) return null; - return ids.map(normalizePropertyId); + return ids.map(normalizeAccountId); }; /** * 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. If an allowlist is - * configured, the resolved property must be in it. + * `propertyId` configured on the MCP installation state. */ export const resolveProperty = (env: Env, property?: string | null): string => { const resolved = property ?? env.MESH_REQUEST_CONTEXT?.state?.propertyId; @@ -40,12 +39,5 @@ 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.", ); } - const normalized = normalizePropertyId(resolved); - const allowed = getAllowedPropertyIds(env); - if (allowed && !allowed.includes(normalized)) { - throw new Error( - `Property "${normalized}" is not allowed for this integration. Allowed properties: ${allowed.join(", ")}.`, - ); - } - return normalized; + return resolved; }; diff --git a/google-analytics/server/tools/accounts.ts b/google-analytics/server/tools/accounts.ts index 7d72401b..cda95836 100644 --- a/google-analytics/server/tools/accounts.ts +++ b/google-analytics/server/tools/accounts.ts @@ -2,7 +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 { getAllowedAccountIds } from "../lib/env.ts"; import { AccountSummariesResponseSchema } from "../lib/schemas.ts"; export const getAccountSummariesTool = (env: Env) => @@ -29,17 +29,12 @@ export const getAccountSummariesTool = (env: Env) => }>; }>; }; - const allowed = getAllowedPropertyIds(env); + const allowed = getAllowedAccountIds(env); if (allowed) { - const filtered = (result.accountSummaries ?? []) - .map((account) => ({ - ...account, - propertySummaries: (account.propertySummaries ?? []).filter( - (prop) => allowed.includes(prop.property), - ), - })) - .filter((account) => account.propertySummaries.length > 0); + const filtered = (result.accountSummaries ?? []).filter((account) => + allowed.includes(account.account ?? ""), + ); return AccountSummariesResponseSchema.parse({ response: { accountSummaries: filtered }, diff --git a/google-analytics/shared/deco.gen.ts b/google-analytics/shared/deco.gen.ts index fabf2fa8..aa0cf0c8 100644 --- a/google-analytics/shared/deco.gen.ts +++ b/google-analytics/shared/deco.gen.ts @@ -7,11 +7,11 @@ 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 + allowedAccountIds: z .array(z.string()) .nullish() .describe( - "Optional allowlist of GA4 property IDs this integration may access. Accepts 'properties/1234567' or just '1234567'. When set, requests for unlisted properties are rejected and get-account-summaries only returns matching accounts/properties. Leave empty to allow all properties the authenticated user can access.", + "Optional allowlist of GA4 account IDs this integration may access. Accepts 'accounts/1234567' or just '1234567'. When set, get-account-summaries only returns matching accounts and their properties. Leave empty to allow all accounts the authenticated user can access.", ), }); From 1d94f6909a41845078e38e43582b248c9962780e Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 1 Jul 2026 13:55:55 -0300 Subject: [PATCH 3/5] fix(google-analytics): enforce account access control on every tool call resolveProperty is now async and, when allowedAccountIds is configured, fetches the property's parent account from the GA4 Admin API to verify it belongs to an allowed account. All tools updated to await the result. Co-Authored-By: Claude Sonnet 4.6 --- google-analytics/server/lib/env.ts | 25 ++++++++++++++++++-- google-analytics/server/tools/ads.ts | 2 +- google-analytics/server/tools/annotations.ts | 2 +- google-analytics/server/tools/properties.ts | 4 ++-- google-analytics/server/tools/reports.ts | 6 ++--- 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/google-analytics/server/lib/env.ts b/google-analytics/server/lib/env.ts index ed47b345..798e416c 100644 --- a/google-analytics/server/lib/env.ts +++ b/google-analytics/server/lib/env.ts @@ -1,4 +1,5 @@ import type { Env } from "../../shared/deco.gen.ts"; +import type { GaClient } from "./ga-client.ts"; export const getGoogleAccessToken = (env: Env): string => { const authorization = env.MESH_REQUEST_CONTEXT?.authorization; @@ -27,17 +28,37 @@ export const getAllowedAccountIds = (env: Env): string[] | null => { }; /** - * Resolves the GA4 property to use for a request. + * Resolves the GA4 property to use for a request and enforces account-level + * access control when allowedAccountIds is configured. * * Prefers the `property` passed to the tool; when omitted, falls back to the * `propertyId` configured on the MCP installation state. + * + * When an allowlist is configured, fetches the property's parent account from + * the GA4 Admin API and rejects requests for properties outside allowed accounts. */ -export const resolveProperty = (env: Env, property?: string | null): string => { +export const resolveProperty = async ( + env: Env, + client: GaClient, + property?: string | null, +): Promise => { const resolved = property ?? env.MESH_REQUEST_CONTEXT?.state?.propertyId; if (!resolved) { throw new Error( "No GA4 property provided. Pass a `property` argument or configure a default `propertyId` in this integration's settings.", ); } + + const allowed = getAllowedAccountIds(env); + if (allowed) { + const prop = (await client.getProperty(resolved)) as { parent?: string }; + const parentAccount = prop.parent; + if (!parentAccount || !allowed.includes(parentAccount)) { + throw new Error( + `Access denied: property "${resolved}" does not belong to an allowed account.`, + ); + } + } + return resolved; }; diff --git a/google-analytics/server/tools/ads.ts b/google-analytics/server/tools/ads.ts index 342bfbce..aa774bed 100644 --- a/google-analytics/server/tools/ads.ts +++ b/google-analytics/server/tools/ads.ts @@ -26,7 +26,7 @@ export const listGoogleAdsLinksTool = (env: Env) => const client = GaClient.fromEnv(env); try { const result = await client.listGoogleAdsLinks( - resolveProperty(env, args.property), + await resolveProperty(env, client, args.property), ); return GoogleAdsLinksResponseSchema.parse({ response: result }); } catch (error) { diff --git a/google-analytics/server/tools/annotations.ts b/google-analytics/server/tools/annotations.ts index b170c95f..be059ba9 100644 --- a/google-analytics/server/tools/annotations.ts +++ b/google-analytics/server/tools/annotations.ts @@ -26,7 +26,7 @@ export const listPropertyAnnotationsTool = (env: Env) => const client = GaClient.fromEnv(env); try { const result = await client.listPropertyAnnotations( - resolveProperty(env, args.property), + await resolveProperty(env, client, args.property), ); return PropertyAnnotationsResponseSchema.parse({ response: result }); } catch (error) { diff --git a/google-analytics/server/tools/properties.ts b/google-analytics/server/tools/properties.ts index 6258684d..b5818c41 100644 --- a/google-analytics/server/tools/properties.ts +++ b/google-analytics/server/tools/properties.ts @@ -29,7 +29,7 @@ export const getPropertyDetailsTool = (env: Env) => const client = GaClient.fromEnv(env); try { const result = await client.getProperty( - resolveProperty(env, args.property), + await resolveProperty(env, client, args.property), ); return PropertyResponseSchema.parse({ response: result }); } catch (error) { @@ -50,7 +50,7 @@ export const getCustomDimensionsAndMetricsTool = (env: Env) => execute: async ({ context: args }) => { const client = GaClient.fromEnv(env); try { - const property = resolveProperty(env, args.property); + const property = await resolveProperty(env, client, args.property); // Both calls are independent — run in parallel to halve latency. const [dimensions, metrics] = await Promise.all([ client.listCustomDimensions(property), diff --git a/google-analytics/server/tools/reports.ts b/google-analytics/server/tools/reports.ts index 6b5c43c3..0df1e4ce 100644 --- a/google-analytics/server/tools/reports.ts +++ b/google-analytics/server/tools/reports.ts @@ -164,7 +164,7 @@ export const runReportTool = (env: Env) => body.returnPropertyQuota = args.returnPropertyQuota; } const response = await client.runReport( - resolveProperty(env, args.property), + await resolveProperty(env, client, args.property), body, ); @@ -306,7 +306,7 @@ export const runFunnelReportTool = (env: Env) => if (args.returnPropertyQuota !== undefined) body.returnPropertyQuota = args.returnPropertyQuota; const response = await client.runFunnelReport( - resolveProperty(env, args.property), + await resolveProperty(env, client, args.property), body, ); @@ -404,7 +404,7 @@ export const runRealtimeReportTool = (env: Env) => body.returnPropertyQuota = args.returnPropertyQuota; } const response = await client.runRealtimeReport( - resolveProperty(env, args.property), + await resolveProperty(env, client, args.property), body, ); From fcdefb1588247dbf4abb23babfa78d11d7a28975 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 1 Jul 2026 14:01:46 -0300 Subject: [PATCH 4/5] refactor(google-analytics): simplify access control to summary-level filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extra getProperty() call in resolveProperty was unnecessary — the LLM only discovers property IDs via get-account-summaries, which already filters by allowedAccountIds. Reverts resolveProperty to a simple sync function. Co-Authored-By: Claude Sonnet 4.6 --- google-analytics/server/lib/env.ts | 27 ++++---------------- google-analytics/server/tools/ads.ts | 2 +- google-analytics/server/tools/annotations.ts | 2 +- google-analytics/server/tools/properties.ts | 4 +-- google-analytics/server/tools/reports.ts | 6 ++--- 5 files changed, 12 insertions(+), 29 deletions(-) diff --git a/google-analytics/server/lib/env.ts b/google-analytics/server/lib/env.ts index 798e416c..6b1991f3 100644 --- a/google-analytics/server/lib/env.ts +++ b/google-analytics/server/lib/env.ts @@ -1,5 +1,4 @@ import type { Env } from "../../shared/deco.gen.ts"; -import type { GaClient } from "./ga-client.ts"; export const getGoogleAccessToken = (env: Env): string => { const authorization = env.MESH_REQUEST_CONTEXT?.authorization; @@ -28,37 +27,21 @@ export const getAllowedAccountIds = (env: Env): string[] | null => { }; /** - * Resolves the GA4 property to use for a request and enforces account-level - * access control when allowedAccountIds is configured. + * 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 an allowlist is configured, fetches the property's parent account from - * the GA4 Admin API and rejects requests for properties outside allowed accounts. + * Access control is enforced at the get-account-summaries level: when + * allowedAccountIds is configured, the LLM only discovers properties that + * belong to allowed accounts and cannot reference others. */ -export const resolveProperty = async ( - env: Env, - client: GaClient, - property?: string | null, -): Promise => { +export const resolveProperty = (env: Env, property?: string | null): string => { const resolved = property ?? env.MESH_REQUEST_CONTEXT?.state?.propertyId; if (!resolved) { throw new Error( "No GA4 property provided. Pass a `property` argument or configure a default `propertyId` in this integration's settings.", ); } - - const allowed = getAllowedAccountIds(env); - if (allowed) { - const prop = (await client.getProperty(resolved)) as { parent?: string }; - const parentAccount = prop.parent; - if (!parentAccount || !allowed.includes(parentAccount)) { - throw new Error( - `Access denied: property "${resolved}" does not belong to an allowed account.`, - ); - } - } - return resolved; }; diff --git a/google-analytics/server/tools/ads.ts b/google-analytics/server/tools/ads.ts index aa774bed..342bfbce 100644 --- a/google-analytics/server/tools/ads.ts +++ b/google-analytics/server/tools/ads.ts @@ -26,7 +26,7 @@ export const listGoogleAdsLinksTool = (env: Env) => const client = GaClient.fromEnv(env); try { const result = await client.listGoogleAdsLinks( - await resolveProperty(env, client, args.property), + resolveProperty(env, args.property), ); return GoogleAdsLinksResponseSchema.parse({ response: result }); } catch (error) { diff --git a/google-analytics/server/tools/annotations.ts b/google-analytics/server/tools/annotations.ts index be059ba9..b170c95f 100644 --- a/google-analytics/server/tools/annotations.ts +++ b/google-analytics/server/tools/annotations.ts @@ -26,7 +26,7 @@ export const listPropertyAnnotationsTool = (env: Env) => const client = GaClient.fromEnv(env); try { const result = await client.listPropertyAnnotations( - await resolveProperty(env, client, args.property), + resolveProperty(env, args.property), ); return PropertyAnnotationsResponseSchema.parse({ response: result }); } catch (error) { diff --git a/google-analytics/server/tools/properties.ts b/google-analytics/server/tools/properties.ts index b5818c41..6258684d 100644 --- a/google-analytics/server/tools/properties.ts +++ b/google-analytics/server/tools/properties.ts @@ -29,7 +29,7 @@ export const getPropertyDetailsTool = (env: Env) => const client = GaClient.fromEnv(env); try { const result = await client.getProperty( - await resolveProperty(env, client, args.property), + resolveProperty(env, args.property), ); return PropertyResponseSchema.parse({ response: result }); } catch (error) { @@ -50,7 +50,7 @@ export const getCustomDimensionsAndMetricsTool = (env: Env) => execute: async ({ context: args }) => { const client = GaClient.fromEnv(env); try { - const property = await resolveProperty(env, client, args.property); + const property = resolveProperty(env, args.property); // Both calls are independent — run in parallel to halve latency. const [dimensions, metrics] = await Promise.all([ client.listCustomDimensions(property), diff --git a/google-analytics/server/tools/reports.ts b/google-analytics/server/tools/reports.ts index 0df1e4ce..6b5c43c3 100644 --- a/google-analytics/server/tools/reports.ts +++ b/google-analytics/server/tools/reports.ts @@ -164,7 +164,7 @@ export const runReportTool = (env: Env) => body.returnPropertyQuota = args.returnPropertyQuota; } const response = await client.runReport( - await resolveProperty(env, client, args.property), + resolveProperty(env, args.property), body, ); @@ -306,7 +306,7 @@ export const runFunnelReportTool = (env: Env) => if (args.returnPropertyQuota !== undefined) body.returnPropertyQuota = args.returnPropertyQuota; const response = await client.runFunnelReport( - await resolveProperty(env, client, args.property), + resolveProperty(env, args.property), body, ); @@ -404,7 +404,7 @@ export const runRealtimeReportTool = (env: Env) => body.returnPropertyQuota = args.returnPropertyQuota; } const response = await client.runRealtimeReport( - await resolveProperty(env, client, args.property), + resolveProperty(env, args.property), body, ); From a44901c1244bae2e485dd204c0862b8940211b28 Mon Sep 17 00:00:00 2001 From: igoramf Date: Wed, 1 Jul 2026 15:38:44 -0300 Subject: [PATCH 5/5] feat(google-analytics): replace allowedAccountIds with allowedPropertyIds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict access at the property level instead of account level. - StateSchema: allowedAccountIds → allowedPropertyIds (numeric IDs accepted) - env.ts: getAllowedPropertyIds normalizes "123" → "properties/123"; resolveProperty validates against allowlist synchronously - accounts.ts: filters properties within each account summary, hides accounts with no allowed properties Co-Authored-By: Claude Sonnet 4.6 --- google-analytics/server/lib/env.ts | 30 ++++++++++++++--------- google-analytics/server/tools/accounts.ts | 15 ++++++++---- google-analytics/shared/deco.gen.ts | 4 +-- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/google-analytics/server/lib/env.ts b/google-analytics/server/lib/env.ts index 6b1991f3..8e331ee5 100644 --- a/google-analytics/server/lib/env.ts +++ b/google-analytics/server/lib/env.ts @@ -10,20 +10,20 @@ export const getGoogleAccessToken = (env: Env): string => { return authorization.replace(/^Bearer\s+/i, ""); }; -// Normalizes "1234567" → "accounts/1234567"; already-prefixed IDs pass through. -function normalizeAccountId(id: string): string { +// Normalizes "1234567" → "properties/1234567"; already-prefixed IDs pass through. +function normalizePropertyId(id: string): string { const clean = String(id).trim(); - return /^\d+$/.test(clean) ? `accounts/${clean}` : clean; + return /^\d+$/.test(clean) ? `properties/${clean}` : clean; } /** - * Returns the normalized allowlist of account IDs for this installation, - * or null if no allowlist is configured (meaning all accounts are allowed). + * Returns the normalized allowlist of property IDs for this installation, + * or null if no allowlist is configured (meaning all properties are allowed). */ -export const getAllowedAccountIds = (env: Env): string[] | null => { - const ids = env.MESH_REQUEST_CONTEXT?.state?.allowedAccountIds; +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(normalizeAccountId); + return ids.map(normalizePropertyId); }; /** @@ -32,9 +32,8 @@ export const getAllowedAccountIds = (env: Env): string[] | null => { * Prefers the `property` passed to the tool; when omitted, falls back to the * `propertyId` configured on the MCP installation state. * - * Access control is enforced at the get-account-summaries level: when - * allowedAccountIds is configured, the LLM only discovers properties that - * belong to allowed accounts and cannot reference others. + * 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; @@ -43,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 cda95836..ecf1044d 100644 --- a/google-analytics/server/tools/accounts.ts +++ b/google-analytics/server/tools/accounts.ts @@ -2,7 +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 { getAllowedAccountIds } from "../lib/env.ts"; +import { getAllowedPropertyIds } from "../lib/env.ts"; import { AccountSummariesResponseSchema } from "../lib/schemas.ts"; export const getAccountSummariesTool = (env: Env) => @@ -29,12 +29,17 @@ export const getAccountSummariesTool = (env: Env) => }>; }>; }; - const allowed = getAllowedAccountIds(env); + const allowed = getAllowedPropertyIds(env); if (allowed) { - const filtered = (result.accountSummaries ?? []).filter((account) => - allowed.includes(account.account ?? ""), - ); + 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 }, diff --git a/google-analytics/shared/deco.gen.ts b/google-analytics/shared/deco.gen.ts index aa0cf0c8..abf4c4aa 100644 --- a/google-analytics/shared/deco.gen.ts +++ b/google-analytics/shared/deco.gen.ts @@ -7,11 +7,11 @@ 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.", ), - allowedAccountIds: z + allowedPropertyIds: z .array(z.string()) .nullish() .describe( - "Optional allowlist of GA4 account IDs this integration may access. Accepts 'accounts/1234567' or just '1234567'. When set, get-account-summaries only returns matching accounts and their properties. Leave empty to allow all accounts the authenticated user can access.", + "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.", ), });