Skip to content

Commit ad4b7a8

Browse files
Introduce org-level GITPOD_IMAGE_AUTH (#20538)
* [db, protocol] Introduce DBOrgEnvVar * [server, spicedb] Introduce and integrate org env vars into internal services * [server, public-api] Added API for org-level environment variables * [dashboard] Add UI for setting/removing GITPOD_IMAGE_AUTH to "Organization Settings" * [db, server] Fix DB queries, mapping to image-build args and fixed tests * [dashboard] Review comment "icon spacing" Co-authored-by: Filip Troníček <[email protected]> * [dashboard] Review comment superfluous key Co-authored-by: Filip Troníček <[email protected]> * [dashboard] more spacing Co-authored-by: Filip Troníček <[email protected]> * [dashboard] Copyright year Co-authored-by: Filip Troníček <[email protected]> * [public-api] Add converter test case --------- Co-authored-by: Filip Troníček <[email protected]>
1 parent e7dbf43 commit ad4b7a8

33 files changed

+9211
-289
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { OrganizationEnvironmentVariable } from "@gitpod/public-api/lib/gitpod/v1/envvar_pb";
8+
import { envVarClient } from "../../service/public-api";
9+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10+
11+
const getListOrgEnvVarQueryKey = (orgId: string) => {
12+
const key: any[] = ["organization", orgId, "envvar", "list"];
13+
14+
return key;
15+
};
16+
17+
const getOrgEnvVarQueryKey = (orgId: string, variableId: string) => {
18+
const key: any[] = ["organization", orgId, "envvar", { variableId }];
19+
20+
return key;
21+
};
22+
23+
export const useListOrganizationEnvironmentVariables = (orgId: string) => {
24+
return useQuery<OrganizationEnvironmentVariable[]>(getListOrgEnvVarQueryKey(orgId), {
25+
queryFn: async () => {
26+
const { environmentVariables } = await envVarClient.listOrganizationEnvironmentVariables({
27+
organizationId: orgId,
28+
});
29+
30+
return environmentVariables;
31+
},
32+
cacheTime: 1000 * 60 * 60 * 24, // one day
33+
});
34+
};
35+
36+
type DeleteEnvironmentVariableArgs = {
37+
variableId: string;
38+
organizationId: string;
39+
};
40+
export const useDeleteOrganizationEnvironmentVariable = () => {
41+
const queryClient = useQueryClient();
42+
43+
return useMutation<void, Error, DeleteEnvironmentVariableArgs>({
44+
mutationFn: async ({ variableId }) => {
45+
void (await envVarClient.deleteOrganizationEnvironmentVariable({
46+
environmentVariableId: variableId,
47+
}));
48+
},
49+
onSuccess: (_, { organizationId, variableId }) => {
50+
queryClient.invalidateQueries({ queryKey: getListOrgEnvVarQueryKey(organizationId) });
51+
queryClient.invalidateQueries({ queryKey: getOrgEnvVarQueryKey(organizationId, variableId) });
52+
},
53+
});
54+
};
55+
56+
type CreateEnvironmentVariableArgs = {
57+
organizationId: string;
58+
name: string;
59+
value: string;
60+
};
61+
export const useCreateOrganizationEnvironmentVariable = () => {
62+
const queryClient = useQueryClient();
63+
64+
return useMutation<OrganizationEnvironmentVariable, Error, CreateEnvironmentVariableArgs>({
65+
mutationFn: async ({ organizationId, name, value }) => {
66+
const { environmentVariable } = await envVarClient.createOrganizationEnvironmentVariable({
67+
organizationId,
68+
name,
69+
value,
70+
});
71+
if (!environmentVariable) {
72+
throw new Error("Failed to create environment variable");
73+
}
74+
75+
return environmentVariable;
76+
},
77+
onSuccess: (_, { organizationId }) => {
78+
queryClient.invalidateQueries({ queryKey: getListOrgEnvVarQueryKey(organizationId) });
79+
},
80+
});
81+
};
82+
83+
type UpdateEnvironmentVariableArgs = CreateEnvironmentVariableArgs & {
84+
variableId: string;
85+
};
86+
export const useUpdateOrganizationEnvironmentVariable = () => {
87+
const queryClient = useQueryClient();
88+
89+
return useMutation<OrganizationEnvironmentVariable, Error, UpdateEnvironmentVariableArgs>({
90+
mutationFn: async ({ variableId, name, value, organizationId }: UpdateEnvironmentVariableArgs) => {
91+
const { environmentVariable } = await envVarClient.updateOrganizationEnvironmentVariable({
92+
environmentVariableId: variableId,
93+
organizationId,
94+
name,
95+
value,
96+
});
97+
if (!environmentVariable) {
98+
throw new Error("Failed to update environment variable");
99+
}
100+
101+
return environmentVariable;
102+
},
103+
onSuccess: (_, { organizationId, variableId }) => {
104+
queryClient.invalidateQueries({ queryKey: getListOrgEnvVarQueryKey(organizationId) });
105+
queryClient.invalidateQueries({ queryKey: getOrgEnvVarQueryKey(organizationId, variableId) });
106+
},
107+
});
108+
};

components/dashboard/src/service/json-rpc-envvar-client.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,29 @@ import { EnvironmentVariableService } from "@gitpod/public-api/lib/gitpod/v1/env
1010
import {
1111
CreateConfigurationEnvironmentVariableRequest,
1212
CreateConfigurationEnvironmentVariableResponse,
13+
CreateOrganizationEnvironmentVariableRequest,
14+
CreateOrganizationEnvironmentVariableResponse,
1315
CreateUserEnvironmentVariableRequest,
1416
CreateUserEnvironmentVariableResponse,
1517
DeleteConfigurationEnvironmentVariableRequest,
1618
DeleteConfigurationEnvironmentVariableResponse,
19+
DeleteOrganizationEnvironmentVariableRequest,
20+
DeleteOrganizationEnvironmentVariableResponse,
1721
DeleteUserEnvironmentVariableRequest,
1822
DeleteUserEnvironmentVariableResponse,
1923
EnvironmentVariableAdmission,
2024
ListConfigurationEnvironmentVariablesRequest,
2125
ListConfigurationEnvironmentVariablesResponse,
26+
ListOrganizationEnvironmentVariablesRequest,
27+
ListOrganizationEnvironmentVariablesResponse,
2228
ListUserEnvironmentVariablesRequest,
2329
ListUserEnvironmentVariablesResponse,
2430
ResolveWorkspaceEnvironmentVariablesRequest,
2531
ResolveWorkspaceEnvironmentVariablesResponse,
2632
UpdateConfigurationEnvironmentVariableRequest,
2733
UpdateConfigurationEnvironmentVariableResponse,
34+
UpdateOrganizationEnvironmentVariableRequest,
35+
UpdateOrganizationEnvironmentVariableResponse,
2836
UpdateUserEnvironmentVariableRequest,
2937
UpdateUserEnvironmentVariableResponse,
3038
} from "@gitpod/public-api/lib/gitpod/v1/envvar_pb";
@@ -163,7 +171,9 @@ export class JsonRpcEnvvarClient implements PromiseClient<typeof EnvironmentVari
163171
req.configurationId,
164172
req.name ?? projectEnvVarfound.name,
165173
req.value ?? "",
166-
req.admission === EnvironmentVariableAdmission.PREBUILD ?? projectEnvVarfound.censored,
174+
req.admission === EnvironmentVariableAdmission.UNSPECIFIED
175+
? projectEnvVarfound.censored
176+
: req.admission === EnvironmentVariableAdmission.PREBUILD,
167177
req.environmentVariableId,
168178
);
169179

@@ -224,6 +234,30 @@ export class JsonRpcEnvvarClient implements PromiseClient<typeof EnvironmentVari
224234
return response;
225235
}
226236

237+
async listOrganizationEnvironmentVariables(
238+
req: PartialMessage<ListOrganizationEnvironmentVariablesRequest>,
239+
): Promise<ListOrganizationEnvironmentVariablesResponse> {
240+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
241+
}
242+
243+
async updateOrganizationEnvironmentVariable(
244+
req: PartialMessage<UpdateOrganizationEnvironmentVariableRequest>,
245+
): Promise<UpdateOrganizationEnvironmentVariableResponse> {
246+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
247+
}
248+
249+
async createOrganizationEnvironmentVariable(
250+
req: PartialMessage<CreateOrganizationEnvironmentVariableRequest>,
251+
): Promise<CreateOrganizationEnvironmentVariableResponse> {
252+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
253+
}
254+
255+
async deleteOrganizationEnvironmentVariable(
256+
req: PartialMessage<DeleteOrganizationEnvironmentVariableRequest>,
257+
): Promise<DeleteOrganizationEnvironmentVariableResponse> {
258+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "Unimplemented");
259+
}
260+
227261
async resolveWorkspaceEnvironmentVariables(
228262
req: PartialMessage<ResolveWorkspaceEnvironmentVariablesRequest>,
229263
): Promise<ResolveWorkspaceEnvironmentVariablesResponse> {

components/dashboard/src/teams/TeamSettings.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
3232
import { useDocumentTitle } from "../hooks/use-document-title";
3333
import { PlainMessage } from "@bufbuild/protobuf";
3434
import { useToast } from "../components/toasts/Toasts";
35+
import { NamedOrganizationEnvvarItem } from "./variables/NamedOrganizationEnvvarItem";
36+
import { useListOrganizationEnvironmentVariables } from "../data/organizations/org-envvar-queries";
37+
import { EnvVar } from "@gitpod/gitpod-protocol";
3538

3639
export default function TeamSettingsPage() {
3740
useDocumentTitle("Organization Settings - General");
@@ -46,6 +49,9 @@ export default function TeamSettingsPage() {
4649
const [teamName, setTeamName] = useState(org?.name || "");
4750
const [updated, setUpdated] = useState(false);
4851

52+
const orgEnvVars = useListOrganizationEnvironmentVariables(org?.id || "");
53+
const gitpodImageAuthEnvVar = orgEnvVars.data?.find((v) => v.name === EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME);
54+
4955
const updateOrg = useUpdateOrgMutation();
5056

5157
const close = () => setModal(false);
@@ -215,6 +221,20 @@ export default function TeamSettingsPage() {
215221
/>
216222
)}
217223

224+
{org?.id && (
225+
<ConfigurationSettingsField>
226+
<Heading3>Docker Registry authentication</Heading3>
227+
<Subheading>Configure Docker registry permissions for the whole organization.</Subheading>
228+
229+
<NamedOrganizationEnvvarItem
230+
disabled={!isOwner}
231+
name={EnvVar.GITPOD_IMAGE_AUTH_ENV_VAR_NAME}
232+
organizationId={org.id}
233+
variable={gitpodImageAuthEnvVar}
234+
/>
235+
</ConfigurationSettingsField>
236+
)}
237+
218238
{user?.organizationId !== org?.id && isOwner && (
219239
<ConfigurationSettingsField>
220240
<Heading3>Delete organization</Heading3>
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Copyright (c) 2025 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { OrganizationEnvironmentVariable } from "@gitpod/public-api/lib/gitpod/v1/envvar_pb";
8+
import { useCallback, useState } from "react";
9+
import { OrganizationRemoveEnvvarModal } from "./OrganizationRemoveEnvvarModal";
10+
import { InputField } from "../../components/forms/InputField";
11+
import { ReactComponent as Stack } from "../../icons/Repository.svg";
12+
import { Button } from "@podkit/buttons/Button";
13+
import { useCreateOrganizationEnvironmentVariable } from "../../data/organizations/org-envvar-queries";
14+
import Modal, { ModalBody, ModalFooter, ModalFooterAlert, ModalHeader } from "../../components/Modal";
15+
import { TextInputField } from "../../components/forms/TextInputField";
16+
import { useToast } from "../../components/toasts/Toasts";
17+
import { LoadingButton } from "@podkit/buttons/LoadingButton";
18+
19+
type Props = {
20+
disabled?: boolean;
21+
organizationId: string;
22+
name: string;
23+
variable: OrganizationEnvironmentVariable | undefined;
24+
};
25+
export const NamedOrganizationEnvvarItem = ({ disabled, organizationId, name, variable }: Props) => {
26+
const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false);
27+
const [showAddModal, setShowAddModal] = useState<boolean>(false);
28+
29+
const value = variable ? "*****" : "not set";
30+
31+
return (
32+
<>
33+
{variable && showRemoveModal && (
34+
<OrganizationRemoveEnvvarModal
35+
variable={variable}
36+
organizationId={organizationId}
37+
onClose={() => setShowRemoveModal(false)}
38+
/>
39+
)}
40+
41+
{showAddModal && (
42+
<AddOrgEnvironmentVariableModal
43+
organizationId={organizationId}
44+
staticName={name}
45+
onClose={() => setShowAddModal(false)}
46+
/>
47+
)}
48+
49+
<InputField disabled={disabled} className="w-full max-w-lg">
50+
<div className="flex flex-col bg-gray-50 dark:bg-gray-800 p-3 rounded-lg">
51+
<div className="flex items-center justify-between">
52+
<div className="flex-1 flex items-center overflow-hidden h-8 gap-2" title={value}>
53+
<span className="w-5 h-5">
54+
<Stack />
55+
</span>
56+
<span className="truncate font-medium text-gray-700 dark:text-gray-200">{name}</span>
57+
</div>
58+
{!disabled && !variable && (
59+
<Button variant="link" onClick={() => setShowAddModal(true)}>
60+
Add
61+
</Button>
62+
)}
63+
{!disabled && variable && (
64+
<Button variant="link" onClick={() => setShowRemoveModal(true)}>
65+
Delete
66+
</Button>
67+
)}
68+
</div>
69+
<div className="mx-7 text-gray-400 dark:text-gray-500 truncate">
70+
<>{value}</>
71+
{disabled && (
72+
<>
73+
&nbsp;&middot;&nbsp; Requires <span className="font-medium">Owner</span> permissions to
74+
change
75+
</>
76+
)}
77+
</div>
78+
</div>
79+
</InputField>
80+
</>
81+
);
82+
};
83+
84+
type AddOrgEnvironmentVariableModalProps = {
85+
organizationId: string;
86+
staticName?: string;
87+
onClose: () => void;
88+
};
89+
export const AddOrgEnvironmentVariableModal = ({
90+
organizationId,
91+
staticName,
92+
onClose,
93+
}: AddOrgEnvironmentVariableModalProps) => {
94+
const { toast } = useToast();
95+
96+
const [name, setName] = useState(staticName || "");
97+
const [value, setValue] = useState("");
98+
const createVariable = useCreateOrganizationEnvironmentVariable();
99+
100+
const addVariable = useCallback(() => {
101+
createVariable.mutateAsync(
102+
{
103+
organizationId,
104+
name,
105+
value,
106+
},
107+
{
108+
onSuccess: () => {
109+
toast("Variable added");
110+
onClose();
111+
},
112+
},
113+
);
114+
}, [createVariable, organizationId, name, value, onClose, toast]);
115+
116+
return (
117+
<Modal visible onClose={onClose} onSubmit={addVariable}>
118+
<ModalHeader>Add a variable</ModalHeader>
119+
<ModalBody>
120+
<div className="mt-8">
121+
<TextInputField
122+
disabled={staticName !== undefined}
123+
label="Name"
124+
autoComplete={"off"}
125+
className="w-full"
126+
value={name}
127+
placeholder="Variable name"
128+
onChange={(name) => setName(name)}
129+
autoFocus
130+
required
131+
/>
132+
</div>
133+
<div className="mt-4">
134+
<TextInputField
135+
label="Value"
136+
autoComplete={"off"}
137+
className="w-full"
138+
value={value}
139+
placeholder="Variable value"
140+
onChange={(value) => setValue(value)}
141+
required
142+
/>
143+
</div>
144+
</ModalBody>
145+
<ModalFooter
146+
alert={
147+
createVariable.isError ? (
148+
<ModalFooterAlert type="danger">
149+
{String(createVariable.error).replace(/Error: Request \w+ failed with message: /, "")}
150+
</ModalFooterAlert>
151+
) : null
152+
}
153+
>
154+
<Button variant="secondary" onClick={onClose}>
155+
Cancel
156+
</Button>
157+
<LoadingButton type="submit" loading={createVariable.isLoading}>
158+
Add Variable
159+
</LoadingButton>
160+
</ModalFooter>
161+
</Modal>
162+
);
163+
};

0 commit comments

Comments
 (0)