From 4aca09a5c2510a69e6ff671c547a5d1c369265b0 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 20 Aug 2026 12:44:18 +0100 Subject: [PATCH] fix(webapp): let an impersonating admin preview the Queue Metrics UI The metrics UI is gated by a per-org feature flag, so the only way to look at it for a real org was to turn it on for every member of that org. An admin impersonating into an org now sees the metrics UI there regardless of the flag. The bypass keys on impersonation rather than admin status, so it does not follow an admin around their own orgs, and it yields to the view-as-user toggle so that toggle still shows exactly what the member sees. It stays behind the gate's existing org-membership lookup, which confines it to the org actually being impersonated into. --- .../v3/RunQueueMetricsPresenter.server.ts | 5 +- .../route.tsx | 2 +- .../route.tsx | 6 ++- .../route.tsx | 2 +- .../route.tsx | 2 +- .../route.tsx | 2 +- .../route.tsx | 1 + .../resources.queues.concurrency-keys.ts | 1 + .../app/utils/queueMetricsUiAccess.test.ts | 54 +++++++++++++++++++ apps/webapp/app/utils/queueMetricsUiAccess.ts | 41 ++++++++++++++ .../app/v3/canAccessQueueMetricsUi.server.ts | 12 ++++- 11 files changed, 120 insertions(+), 8 deletions(-) create mode 100644 apps/webapp/app/utils/queueMetricsUiAccess.test.ts create mode 100644 apps/webapp/app/utils/queueMetricsUiAccess.ts diff --git a/apps/webapp/app/presenters/v3/RunQueueMetricsPresenter.server.ts b/apps/webapp/app/presenters/v3/RunQueueMetricsPresenter.server.ts index 0defa6178ab..c5096145ad2 100644 --- a/apps/webapp/app/presenters/v3/RunQueueMetricsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunQueueMetricsPresenter.server.ts @@ -41,6 +41,7 @@ const DELAY_GRID_MS = 5 * 60 * 1000; * waiting to start, live counts and recent delay percentiles. Null when flag off. */ export async function resolveRunQueueMetrics(options: { + request: Request; userId: string; organizationSlug: string; projectParam: string; @@ -52,10 +53,10 @@ export async function resolveRunQueueMetrics(options: { queue: { name: string; concurrencyKey?: string | null }; }; }): Promise { - const { userId, organizationSlug, projectParam, envParam, run } = options; + const { request, userId, organizationSlug, projectParam, envParam, run } = options; try { - if (!(await canAccessQueueMetricsUi({ userId, organizationSlug }))) { + if (!(await canAccessQueueMetricsUi({ request, userId, organizationSlug }))) { return null; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx index 31f14f8b70d..6e2fe10d32e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.$dashboardKey/route.tsx @@ -63,7 +63,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // URL), so gate it per-org like the rest of the Queue Metrics view. if ( dashboardKey === "queues" && - !(await canAccessQueueMetricsUi({ userId: user.id, organizationSlug })) + !(await canAccessQueueMetricsUi({ request, userId: user.id, organizationSlug })) ) { throw new Response(undefined, { status: 404, statusText: "Not found" }); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 597901527a1..b94262081e5 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -170,7 +170,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // Per-org gate for the metrics UI. When off, this org gets the classic Queues page and // no metrics query fires. - const queueMetricsUiEnabled = await canAccessQueueMetricsUi({ userId, organizationSlug }); + const queueMetricsUiEnabled = await canAccessQueueMetricsUi({ + request, + userId, + organizationSlug, + }); const maxPeriodDays = queueMetricsUiEnabled ? await queueMetricsMaxPeriodDays(environment.organizationId) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx index c0c0f5016c6..59ced90c42e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam/route.tsx @@ -106,7 +106,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // This whole page is part of the metrics UI; gate it per-org (the list already hides // the only link to it, this is defense in depth). - if (!(await canAccessQueueMetricsUi({ userId, organizationSlug }))) { + if (!(await canAccessQueueMetricsUi({ request, userId, organizationSlug }))) { throw new Response(undefined, { status: 404, statusText: "Not found" }); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 590556e087b..e286a0c056f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -159,7 +159,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // Live queue counts for the sidebar Queue property (flag on only; the property itself // is not rendered without them, so flag off = no extra reads and no UI change). let queueMetrics: { live: QueueLiveCounts; ids: QueueMetricIds } | null = null; - if (task.queue && (await canAccessQueueMetricsUi({ userId, organizationSlug }))) { + if (task.queue && (await canAccessQueueMetricsUi({ request, userId, organizationSlug }))) { const queueName = task.queue.name; const [lengths, concurrency] = await Promise.all([ engine.lengthOfQueues(environment, [queueName]), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index db829fc74b6..32a16c6896d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -119,7 +119,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // Live queue counts (two O(1) Redis reads) shown in the sidebar; history charts fetch // client-side through the metric resource. Flag off = no extra reads at all. let queueMetrics: { live: QueueLiveCounts; ids: QueueMetricIds } | null = null; - if (task.queue && (await canAccessQueueMetricsUi({ userId, organizationSlug }))) { + if (task.queue && (await canAccessQueueMetricsUi({ request, userId, organizationSlug }))) { const queueName = task.queue.name; const [lengths, concurrency] = await Promise.all([ engine.lengthOfQueues(environment, [queueName]), diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx index 098015978e2..90e67c8fc1b 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx @@ -171,6 +171,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { // `type === "run" | "span"` discriminant downstream in `SpanView`. if (result.type === "run") { const queueMetrics = await resolveRunQueueMetrics({ + request, userId, organizationSlug, projectParam, diff --git a/apps/webapp/app/routes/resources.queues.concurrency-keys.ts b/apps/webapp/app/routes/resources.queues.concurrency-keys.ts index 992ace788a5..67c2b9f500a 100644 --- a/apps/webapp/app/routes/resources.queues.concurrency-keys.ts +++ b/apps/webapp/app/routes/resources.queues.concurrency-keys.ts @@ -107,6 +107,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { // this endpoint's data isn't reachable for orgs that can't see the UI. 404 (not 403) to hide it. if ( !(await canAccessQueueMetricsUi({ + request, userId, organizationSlug: environment.organization.slug, })) diff --git a/apps/webapp/app/utils/queueMetricsUiAccess.test.ts b/apps/webapp/app/utils/queueMetricsUiAccess.test.ts new file mode 100644 index 00000000000..8ee56a48255 --- /dev/null +++ b/apps/webapp/app/utils/queueMetricsUiAccess.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { resolveQueueMetricsUiAccess } from "./queueMetricsUiAccess"; + +describe("resolveQueueMetricsUiAccess", () => { + it("allows access when the org flag is on", () => { + expect( + resolveQueueMetricsUiAccess({ + flagEnabled: true, + isImpersonating: false, + isViewingAsUser: false, + }) + ).toBe(true); + }); + + it("denies access when the org flag is off and the session is not impersonating", () => { + expect( + resolveQueueMetricsUiAccess({ + flagEnabled: false, + isImpersonating: false, + isViewingAsUser: false, + }) + ).toBe(false); + }); + + it("allows an impersonating admin to preview the UI with the org flag off", () => { + expect( + resolveQueueMetricsUiAccess({ + flagEnabled: false, + isImpersonating: true, + isViewingAsUser: false, + }) + ).toBe(true); + }); + + it("withholds the preview while the admin is viewing as the user", () => { + expect( + resolveQueueMetricsUiAccess({ + flagEnabled: false, + isImpersonating: true, + isViewingAsUser: true, + }) + ).toBe(false); + }); + + it("keeps access for an org whose flag is on even while viewing as the user", () => { + expect( + resolveQueueMetricsUiAccess({ + flagEnabled: true, + isImpersonating: true, + isViewingAsUser: true, + }) + ).toBe(true); + }); +}); diff --git a/apps/webapp/app/utils/queueMetricsUiAccess.ts b/apps/webapp/app/utils/queueMetricsUiAccess.ts new file mode 100644 index 00000000000..2b901833713 --- /dev/null +++ b/apps/webapp/app/utils/queueMetricsUiAccess.ts @@ -0,0 +1,41 @@ +/** + * The rule for who sees the Queue Metrics dashboard UI. + * + * Kept pure and free of server-only imports so it can be unit tested directly, + * and so there is one definition of the rule for the server gate to share. + */ + +/** + * Resolves the per-org feature flag against the request's impersonation state. + * + * The bypass exists so an admin can preview the UI for a real org before it is + * revealed to that org's members, which the flag alone cannot express: flags are + * org-scoped, so turning one on to look at the UI exposes every member of the org. + * + * It keys on impersonation rather than `user.admin` because impersonation is + * scoped to one org and is a deliberate act, where admin status is neither — an + * admin browsing their own orgs would otherwise silently get the preview + * everywhere. + * + * It yields to `isViewingAsUser`, which is the admin asking to see exactly what + * the member sees; previewing unreleased UI through that toggle would make it + * lie. Suppressing the preview there only ever hides a read-only view, so it + * stays inside the display-only contract that toggle is held to. + * + * The caller is responsible for only reporting `isImpersonating` for an + * impersonation into a member of the org being resolved, so the bypass cannot + * reach across orgs. + */ +export function resolveQueueMetricsUiAccess(options: { + flagEnabled: boolean; + isImpersonating: boolean; + isViewingAsUser: boolean; +}): boolean { + const { flagEnabled, isImpersonating, isViewingAsUser } = options; + + if (flagEnabled) { + return true; + } + + return isImpersonating && !isViewingAsUser; +} diff --git a/apps/webapp/app/v3/canAccessQueueMetricsUi.server.ts b/apps/webapp/app/v3/canAccessQueueMetricsUi.server.ts index 0e3c142b272..e225fd507b7 100644 --- a/apps/webapp/app/v3/canAccessQueueMetricsUi.server.ts +++ b/apps/webapp/app/v3/canAccessQueueMetricsUi.server.ts @@ -1,4 +1,6 @@ import { prisma } from "~/db.server"; +import { getImpersonationState } from "~/services/impersonation.server"; +import { resolveQueueMetricsUiAccess } from "~/utils/queueMetricsUiAccess"; import { FEATURE_FLAG } from "~/v3/featureFlags"; import { makeFlag } from "~/v3/featureFlags.server"; @@ -6,6 +8,7 @@ import { makeFlag } from "~/v3/featureFlags.server"; // FeatureFlag table value, which wins over the off-by-default. Ingestion/emission is a // separate global flag; this only decides whether an org sees the metrics view. export async function canAccessQueueMetricsUi(options: { + request: Request; userId: string; organizationSlug: string; }): Promise { @@ -18,9 +21,16 @@ export async function canAccessQueueMetricsUi(options: { }); const flag = makeFlag(); - return flag({ + const flagEnabled = await flag({ key: FEATURE_FLAG.queueMetricsUiEnabled, defaultValue: false, overrides: (org?.featureFlags as Record) ?? {}, }); + + const { isImpersonating, isViewingAsUser } = + flagEnabled || !org + ? { isImpersonating: false, isViewingAsUser: false } + : await getImpersonationState(options.request, options.userId); + + return resolveQueueMetricsUiAccess({ flagEnabled, isImpersonating, isViewingAsUser }); }