Skip to content

Commit d86d6cb

Browse files
committed
Org level feature flag for query access
1 parent 1b3e56c commit d86d6cb

File tree

5 files changed

+66
-8
lines changed

5 files changed

+66
-8
lines changed

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { TaskIconSmall } from "~/assets/icons/TaskIcon";
3434
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
3535
import { Avatar } from "~/components/primitives/Avatar";
3636
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
37+
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
3738
import { useFeatures } from "~/hooks/useFeatures";
3839
import { type MatchedOrganization } from "~/hooks/useOrganizations";
3940
import { type MatchedProject } from "~/hooks/useProject";
@@ -130,6 +131,7 @@ export function SideMenu({
130131
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
131132
const isAdmin = useHasAdminAccess();
132133
const { isManagedCloud } = useFeatures();
134+
const featureFlags = useFeatureFlags();
133135

134136
useEffect(() => {
135137
const handleScroll = () => {
@@ -272,7 +274,7 @@ export function SideMenu({
272274
to={v3TestPath(organization, project, environment)}
273275
data-action="test"
274276
/>
275-
{user.admin && (
277+
{(user.admin || featureFlags.hasQueryAccess) && (
276278
<SideMenuItem
277279
name="Query"
278280
icon={TableCellsIcon}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { type UIMatch } from "@remix-run/react";
2+
import { useOptionalOrganization } from "./useOrganizations";
3+
4+
/**
5+
* Hook to access organization-level feature flags.
6+
* Returns the feature flags from the current organization, or an empty object if no organization is found.
7+
*/
8+
export function useFeatureFlags(matches?: UIMatch[]) {
9+
const org = useOptionalOrganization(matches);
10+
return org?.featureFlags ?? {};
11+
}

apps/webapp/app/presenters/OrganizationsPresenter.server.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "./SelectBestEnvironmentPresenter.server";
1111
import { sortEnvironments } from "~/utils/environmentSort";
1212
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
13+
import { validatePartialFeatureFlags } from "~/v3/featureFlags.server";
1314

1415
export class OrganizationsPresenter {
1516
#prismaClient: PrismaClient;
@@ -132,6 +133,7 @@ export class OrganizationsPresenter {
132133
slug: true,
133134
title: true,
134135
avatar: true,
136+
featureFlags: true,
135137
projects: {
136138
where: { deletedAt: null, version: "V3" },
137139
select: {
@@ -152,11 +154,17 @@ export class OrganizationsPresenter {
152154
});
153155

154156
return orgs.map((org) => {
157+
const flagsResult = org.featureFlags
158+
? validatePartialFeatureFlags(org.featureFlags as Record<string, unknown>)
159+
: ({ success: false } as const);
160+
const flags = flagsResult.success ? flagsResult.data : {};
161+
155162
return {
156163
id: org.id,
157164
slug: org.slug,
158165
title: org.title,
159166
avatar: parseAvatar(org.avatar, defaultAvatar),
167+
featureFlags: flags,
160168
projects: org.projects.map((project) => ({
161169
id: project.id,
162170
slug: project.slug,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,15 +62,50 @@ import { SimpleTooltip } from "~/components/primitives/Tooltip";
6262
import { useEnvironment } from "~/hooks/useEnvironment";
6363
import { useOrganization } from "~/hooks/useOrganizations";
6464
import { useProject } from "~/hooks/useProject";
65+
import { prisma } from "~/db.server";
6566
import { findProjectBySlug } from "~/models/project.server";
6667
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
6768
import { QueryPresenter, type QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
6869
import { executeQuery, type QueryScope } from "~/services/queryService.server";
6970
import { requireUser } from "~/services/session.server";
7071
import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport";
7172
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
73+
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
7274
import { querySchemas } from "~/v3/querySchemas";
7375

76+
async function hasQueryAccess(
77+
userId: string,
78+
isAdmin: boolean,
79+
organizationSlug: string
80+
): Promise<boolean> {
81+
if (isAdmin) {
82+
return true;
83+
}
84+
85+
// Check organization feature flags
86+
const organization = await prisma.organization.findFirst({
87+
where: {
88+
slug: organizationSlug,
89+
members: { some: { userId } },
90+
},
91+
select: {
92+
featureFlags: true,
93+
},
94+
});
95+
96+
if (!organization?.featureFlags) {
97+
return false;
98+
}
99+
100+
const flags = organization.featureFlags as Record<string, unknown>;
101+
const hasQueryAccessResult = validateFeatureFlagValue(
102+
FEATURE_FLAG.hasQueryAccess,
103+
flags.hasQueryAccess
104+
);
105+
106+
return hasQueryAccessResult.success && hasQueryAccessResult.data === true;
107+
}
108+
74109
const scopeOptions = [
75110
{ value: "environment", label: "Environment" },
76111
{ value: "project", label: "Project" },
@@ -79,13 +114,13 @@ const scopeOptions = [
79114

80115
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
81116
const user = await requireUser(request);
117+
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
82118

83-
if (!user.admin) {
119+
const canAccess = await hasQueryAccess(user.id, user.admin, organizationSlug);
120+
if (!canAccess) {
84121
throw redirect("/");
85122
}
86123

87-
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
88-
89124
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
90125
if (!project) {
91126
throw new Response(undefined, {
@@ -120,17 +155,16 @@ const ActionSchema = z.object({
120155

121156
export const action = async ({ request, params }: ActionFunctionArgs) => {
122157
const user = await requireUser(request);
158+
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
123159

124-
// Temporarily admin-only
125-
if (!user.admin) {
160+
const canAccess = await hasQueryAccess(user.id, user.admin, organizationSlug);
161+
if (!canAccess) {
126162
return typedjson(
127163
{ error: "Unauthorized", rows: null, columns: null, stats: null },
128164
{ status: 403 }
129165
);
130166
}
131167

132-
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
133-
134168
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
135169
if (!project) {
136170
return typedjson(

apps/webapp/app/v3/featureFlags.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ export const FEATURE_FLAG = {
55
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
66
runsListRepository: "runsListRepository",
77
taskEventRepository: "taskEventRepository",
8+
hasQueryAccess: "hasQueryAccess",
89
} as const;
910

1011
const FeatureFlagCatalog = {
1112
[FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(),
1213
[FEATURE_FLAG.runsListRepository]: z.enum(["clickhouse", "postgres"]),
1314
[FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]),
15+
[FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(),
1416
};
1517

1618
type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
@@ -83,6 +85,7 @@ export const setFlags = makeSetFlags();
8385

8486
// Create a Zod schema from the existing catalog
8587
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
88+
export type FeatureFlagCatalog = z.infer<typeof FeatureFlagCatalogSchema>;
8689

8790
// Utility function to validate a feature flag value
8891
export function validateFeatureFlagValue<T extends FeatureFlagKey>(

0 commit comments

Comments
 (0)