Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -52,10 +53,10 @@ export async function resolveRunQueueMetrics(options: {
queue: { name: string; concurrencyKey?: string | null };
};
}): Promise<RunQueueMetrics | null> {
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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
Expand Down
54 changes: 54 additions & 0 deletions apps/webapp/app/utils/queueMetricsUiAccess.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
41 changes: 41 additions & 0 deletions apps/webapp/app/utils/queueMetricsUiAccess.ts
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 11 additions & 1 deletion apps/webapp/app/v3/canAccessQueueMetricsUi.server.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
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";

// Per-org gate for the Queue Metrics dashboard UI. Org override wins over the global
// 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<boolean> {
Expand All @@ -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<string, unknown>) ?? {},
});

const { isImpersonating, isViewingAsUser } =
flagEnabled || !org
? { isImpersonating: false, isViewingAsUser: false }
: await getImpersonationState(options.request, options.userId);

return resolveQueueMetricsUiAccess({ flagEnabled, isImpersonating, isViewingAsUser });
}