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 }); }