Skip to content
Open
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 @@ -9,6 +9,7 @@ import {
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
import { SlackIcon } from "@trigger.dev/companyicons";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
Comment on lines +12 to 13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t gate this sidebar item by feature flag at nav level.

This hides the menu entry and diverges from the project’s established pattern where sidebar items are always shown and authorization is enforced in route loaders.

🔧 Suggested fix
-import { useFeatureFlags } from "~/hooks/useFeatureFlags";
 import { useFeatures } from "~/hooks/useFeatures";
@@
   const { isManagedCloud } = useFeatures();
-  const featureFlags = useFeatureFlags();
@@
-          {featureFlags.hasPrivateConnections && (
-            <SideMenuItem
-              name="Private Connections"
-              icon={LockClosedIcon}
-              activeIconColor="text-purple-500"
-              to={v3PrivateConnectionsPath(organization)}
-              data-action="private-connections"
-            />
-          )}
+          <SideMenuItem
+            name="Private Connections"
+            icon={LockClosedIcon}
+            activeIconColor="text-purple-500"
+            to={v3PrivateConnectionsPath(organization)}
+            data-action="private-connections"
+          />
Based on learnings: In triggerdotdev/trigger.dev, sidebar navigation items are intentionally NOT gated behind feature-flag or permission checks at the nav level; authorization is enforced at the route/loader level.

Also applies to: 52-53, 110-118

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx`
around lines 12 - 13, The OrganizationSettingsSideMenu is currently hiding
sidebar items using feature-flag hooks (useFeatureFlags, useFeatures) at the nav
level; remove that gating so menu entries are always rendered and let
route/loader authorization handle access instead: update the
OrganizationSettingsSideMenu component to stop conditionally rendering the menu
item(s) based on useFeatureFlags/useFeatures (including the blocks around the
link(s) referenced in the component where features are checked) so the links are
always present, but keep any existing route-level guards intact.

import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { cn } from "~/utils/cn";
Expand Down Expand Up @@ -48,7 +49,8 @@ export function OrganizationSettingsSideMenu({
organization: MatchedOrganization;
buildInfo: BuildInfo;
}) {
const { isManagedCloud, hasPrivateConnections } = useFeatures();
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
const currentPlan = useCurrentPlan();
const isAdmin = useHasAdminAccess();
const showBuildInfo = isAdmin || !isManagedCloud;
Expand Down Expand Up @@ -105,7 +107,7 @@ export function OrganizationSettingsSideMenu({
/>
</>
)}
{hasPrivateConnections && (
{featureFlags.hasPrivateConnections && (
<SideMenuItem
name="Private Connections"
icon={LockClosedIcon}
Expand Down
9 changes: 7 additions & 2 deletions apps/webapp/app/presenters/OrganizationsPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "./SelectBestEnvironmentPresenter.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
import { env } from "~/env.server";
import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server";

export class OrganizationsPresenter {
Expand Down Expand Up @@ -154,8 +155,12 @@ export class OrganizationsPresenter {
},
});

// Get global feature flags (no overrides or defaults)
const globalFlags = await flags();
// Get global feature flags with env-var-based defaults
const globalFlags = await flags({
defaultValues: {
hasPrivateConnections: env.PRIVATE_CONNECTIONS_ENABLED === "1",
},
});

return orgs.map((org) => {
const orgFlagsResult = org.featureFlags
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import { prisma } from "~/db.server";
import { featuresForRequest } from "~/features.server";
import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server";
import { logger } from "~/services/logger.server";
import { getPrivateLinks } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
Expand Down Expand Up @@ -44,8 +44,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);

const { hasPrivateConnections } = featuresForRequest(request);
if (!hasPrivateConnections) {
const canAccess = await canAccessPrivateConnections({ organizationSlug, userId });
if (!canAccess) {
return redirect(organizationPath({ slug: organizationSlug }));
Comment on lines +47 to 49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard the action with the same canAccessPrivateConnections check.

Line 47 gates only the loader. The delete action (Line 71 onward) is still callable directly and can mutate state without feature access, which weakens enforcement of the org-level flag.

🔧 Suggested fix
 export const action = async ({ request, params }: ActionFunctionArgs) => {
   const userId = await requireUserId(request);
   const { organizationSlug } = OrganizationParamsSchema.parse(params);
+  const canAccess = await canAccessPrivateConnections({ organizationSlug, userId });
+
+  if (!canAccess) {
+    return redirect(organizationPath({ slug: organizationSlug }));
+  }
 
   if (request.method !== "DELETE" && request.method !== "POST") {
     return json({ error: "Method not allowed" }, { status: 405 });
   }

Also applies to: 71-113

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/webapp/app/routes/_app.orgs`.$organizationSlug.settings.private-connections._index/route.tsx
around lines 47 - 49, The delete action is not currently protected by the same
org-level check as the loader; call canAccessPrivateConnections({
organizationSlug, userId }) at the start of the action handler (the export const
action or the function handling the delete flow around the deletion logic
starting at line ~71) and, if it returns false, short-circuit by returning a
redirect to organizationPath({ slug: organizationSlug }) (or an appropriate
unauthorized response) before performing any deletion or state mutation; ensure
you reference the same organizationSlug and userId used in the loader so the
guard logic is identical.

}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { Select, SelectItem } from "~/components/primitives/Select";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { featuresForRequest } from "~/features.server";
import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server";
import {
redirectWithErrorMessage,
redirectWithSuccessMessage,
Expand Down Expand Up @@ -56,8 +56,8 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);

const { hasPrivateConnections } = featuresForRequest(request);
if (!hasPrivateConnections) {
const canAccess = await canAccessPrivateConnections({ organizationSlug, userId });
if (!canAccess) {
return redirect(organizationPath({ slug: organizationSlug }));
}
Comment on lines +59 to 62
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply the same access gate in action to prevent direct POST bypass.

The loader is protected, but create mutation in Line 95+ is still reachable via direct form POST without feature access validation.

🔧 Suggested fix
 export const action: ActionFunction = async ({ request, params }) => {
   const userId = await requireUserId(request);
   const { organizationSlug } = OrganizationParamsSchema.parse(params);
+  const canAccess = await canAccessPrivateConnections({ organizationSlug, userId });
+
+  if (!canAccess) {
+    return redirect(organizationPath({ slug: organizationSlug }));
+  }
 
   const formData = await request.formData();
   const submission = parse(formData, { schema });

Also applies to: 95-154

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/webapp/app/routes/_app.orgs`.$organizationSlug.settings.private-connections.new/route.tsx
around lines 59 - 62, The POST/action handler must enforce the same access gate
as the loader: call canAccessPrivateConnections({ organizationSlug, userId }) at
the start of the action (before performing the create mutation), and if it
returns false perform the same redirect to organizationPath({ slug:
organizationSlug }) (or return an appropriate unauthorized response) to prevent
direct form POST bypass; update the action function in this file to reuse the
canAccessPrivateConnections check and short-circuit before running the mutation
logic that currently lives after line 95.


Expand Down
27 changes: 27 additions & 0 deletions apps/webapp/app/v3/canAccessPrivateConnections.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";

export async function canAccessPrivateConnections(options: {
organizationSlug: string;
userId: string;
}): Promise<boolean> {
const { organizationSlug, userId } = options;

const org = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});

const flag = makeFlag();
return flag({
key: FEATURE_FLAG.hasPrivateConnections,
defaultValue: env.PRIVATE_CONNECTIONS_ENABLED === "1",
overrides: (org?.featureFlags as Record<string, unknown>) ?? {},
});
Comment on lines +22 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Managed cloud requirement intentionally dropped for private connections

The old hasPrivateConnections in features.server.ts:18-23 required BOTH PRIVATE_CONNECTIONS_ENABLED === "1" AND the host being managed cloud (isManagedCloud(host)). The new implementation in canAccessPrivateConnections.server.ts:24 and OrganizationsPresenter.server.ts:161 only checks the env var (or feature flag override), dropping the managed cloud gate. This means self-hosted instances with PRIVATE_CONNECTIONS_ENABLED=1 will now see Private Connections in the UI and be able to access the routes, which was previously blocked. This appears intentional (moving to per-org flag control), but is worth confirming since it broadens the feature surface.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +11 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Return false when org lookup fails before evaluating the flag.

As written, this can return true from env default even when the user is not a member or the org slug doesn’t exist. That makes the helper semantically unsafe as an access primitive.

🔧 Suggested fix
   const org = await prisma.organization.findFirst({
@@
   });
 
+  if (!org) {
+    return false;
+  }
+
   const flag = makeFlag();
   return flag({
     key: FEATURE_FLAG.hasPrivateConnections,
     defaultValue: env.PRIVATE_CONNECTIONS_ENABLED === "1",
-    overrides: (org?.featureFlags as Record<string, unknown>) ?? {},
+    overrides: (org.featureFlags as Record<string, unknown>) ?? {},
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const org = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});
const flag = makeFlag();
return flag({
key: FEATURE_FLAG.hasPrivateConnections,
defaultValue: env.PRIVATE_CONNECTIONS_ENABLED === "1",
overrides: (org?.featureFlags as Record<string, unknown>) ?? {},
});
const org = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});
if (!org) {
return false;
}
const flag = makeFlag();
return flag({
key: FEATURE_FLAG.hasPrivateConnections,
defaultValue: env.PRIVATE_CONNECTIONS_ENABLED === "1",
overrides: (org.featureFlags as Record<string, unknown>) ?? {},
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/webapp/app/v3/canAccessPrivateConnections.server.ts` around lines 11 -
26, The org lookup can be null which should short-circuit access; change the
flow in the code that calls prisma.organization.findFirst (variable org) so that
if org is falsy you immediately return false instead of evaluating the feature
flag; keep using makeFlag and FEATURE_FLAG.hasPrivateConnections only when org
exists and pass overrides from org.featureFlags, but do not fall back to
env.PRIVATE_CONNECTIONS_ENABLED for non-members or missing orgs.

}
14 changes: 9 additions & 5 deletions apps/webapp/app/v3/featureFlags.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const FEATURE_FLAG = {
hasLogsPageAccess: "hasLogsPageAccess",
hasAiAccess: "hasAiAccess",
hasAiModelsAccess: "hasAiModelsAccess",
hasPrivateConnections: "hasPrivateConnections",
} as const;

const FeatureFlagCatalog = {
Expand All @@ -19,6 +20,7 @@ const FeatureFlagCatalog = {
[FEATURE_FLAG.hasLogsPageAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasAiAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasAiModelsAccess]: z.coerce.boolean(),
[FEATURE_FLAG.hasPrivateConnections]: z.coerce.boolean(),
};

type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
Expand Down Expand Up @@ -47,21 +49,23 @@ export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) {

const flagSchema = FeatureFlagCatalog[opts.key];

if (opts.overrides?.[opts.key]) {
if (opts.overrides?.[opts.key] !== undefined) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 makeFlag override fix changes behavior for falsy org flag values across all callers

The change from if (opts.overrides?.[opts.key]) (truthy check) to if (opts.overrides?.[opts.key] !== undefined) at featureFlags.server.ts:52 is a correctness fix, but it subtly changes behavior for ALL existing callers of makeFlag, not just the new hasPrivateConnections code. Specifically, if any organization has a boolean flag like hasQueryAccess: false or hasAiAccess: false stored in their featureFlags JSON column, the old code would silently ignore this false override and fall through to the DB global value. The new code correctly respects the false override. I verified all existing callers (canAccessQuery, canAccessAi, canAccessAiModels, eventRepository, workerGroupService, runsRepository) — for non-boolean flags this has no effect, and for boolean flags the fix is the correct behavior. However, if any orgs have false flags that were previously being bypassed, they will now take effect.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const parsed = flagSchema.safeParse(opts.overrides[opts.key]);

if (parsed.success) {
return parsed.data;
}
}

const parsed = flagSchema.safeParse(value?.value);
if (value !== null) {
const parsed = flagSchema.safeParse(value.value);

if (!parsed.success) {
return opts.defaultValue;
if (parsed.success) {
return parsed.data;
}
}

return parsed.data;
return opts.defaultValue;
}

return flag;
Expand Down
Loading