Skip to content

Commit 0bc795b

Browse files
mustard-mhakosyakovselfcontainedeasyCZ
authored
Allow to disable workspace sharing in team settings (#17042)
* server impl * dashboard * dashboard improve * 1 * add permission check * update error message * improve mutation * 💄 * * remote `deleted` field in respond * dashboard use new data from server * update db select * address UI feedback * disable query when no org loaded yet * rename team to org * allow to stop sharring always * fix * Fix * Fix * Fix * Fix --------- Co-authored-by: Anton Kosyakov <[email protected]> Co-authored-by: Brad Harris <[email protected]> Co-authored-by: Milan Pavlik <[email protected]>
1 parent c032c4f commit 0bc795b

File tree

12 files changed

+239
-4
lines changed

12 files changed

+239
-4
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Copyright (c) 2023 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 { OrganizationSettings } from "@gitpod/gitpod-protocol";
8+
import { useQuery } from "@tanstack/react-query";
9+
import { getGitpodService } from "../../service/service";
10+
import { useCurrentOrg } from "./orgs-query";
11+
12+
export type OrgSettingsResult = OrganizationSettings;
13+
14+
export const useOrgSettingsQuery = () => {
15+
const org = useCurrentOrg().data;
16+
17+
return useQuery<OrgSettingsResult>({
18+
queryKey: getOrgSettingsQueryKey(org?.id ?? ""),
19+
staleTime: 1000 * 60 * 1, // 1 minute
20+
queryFn: async () => {
21+
if (!org) {
22+
throw new Error("No org selected.");
23+
}
24+
25+
const settings = await getGitpodService().server.getOrgSettings(org.id);
26+
return settings || null;
27+
},
28+
enabled: !!org,
29+
});
30+
};
31+
32+
export const getOrgSettingsQueryKey = (teamId: string) => ["org-settings", { teamId }];
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Copyright (c) 2023 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 { OrganizationSettings } from "@gitpod/gitpod-protocol";
8+
import { useMutation, useQueryClient } from "@tanstack/react-query";
9+
import { getGitpodService } from "../../service/service";
10+
import { getOrgSettingsQueryKey, OrgSettingsResult } from "./org-settings-query";
11+
import { useCurrentOrg } from "./orgs-query";
12+
13+
type UpdateOrganizationSettingsArgs = Pick<OrganizationSettings, "workspaceSharingDisabled">;
14+
15+
export const useUpdateOrgSettingsMutation = () => {
16+
const queryClient = useQueryClient();
17+
const team = useCurrentOrg().data;
18+
const teamId = team?.id || "";
19+
20+
return useMutation<OrganizationSettings, Error, UpdateOrganizationSettingsArgs>({
21+
mutationFn: async ({ workspaceSharingDisabled }) => {
22+
return await getGitpodService().server.updateOrgSettings(teamId, { workspaceSharingDisabled });
23+
},
24+
onSuccess: (newData, _) => {
25+
const queryKey = getOrgSettingsQueryKey(teamId);
26+
queryClient.setQueryData<OrgSettingsResult>(queryKey, newData);
27+
queryClient.invalidateQueries({ queryKey });
28+
},
29+
});
30+
};

components/dashboard/src/teams/TeamSettings.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7+
import { OrganizationSettings } from "@gitpod/gitpod-protocol";
78
import React, { useCallback, useState } from "react";
89
import Alert from "../components/Alert";
910
import { Button } from "../components/Button";
11+
import CheckBox from "../components/CheckBox";
1012
import ConfirmationModal from "../components/ConfirmationModal";
1113
import { TextInputField } from "../components/forms/TextInputField";
1214
import { Heading2, Subheading } from "../components/typography/headings";
15+
import { useUpdateOrgSettingsMutation } from "../data/organizations/update-org-settings-mutation";
16+
import { useOrgSettingsQuery } from "../data/organizations/org-settings-query";
1317
import { useCurrentOrg, useOrganizationsInvalidator } from "../data/organizations/orgs-query";
1418
import { useUpdateOrgMutation } from "../data/organizations/update-org-mutation";
1519
import { useOnBlurError } from "../hooks/use-onblur-error";
@@ -18,7 +22,7 @@ import { gitpodHostUrl } from "../service/service";
1822
import { useCurrentUser } from "../user-context";
1923
import { OrgSettingsPage } from "./OrgSettingsPage";
2024

21-
export default function TeamSettings() {
25+
export default function TeamSettingsPage() {
2226
const user = useCurrentUser();
2327
const org = useCurrentOrg().data;
2428
const invalidateOrgs = useOrganizationsInvalidator();
@@ -28,6 +32,21 @@ export default function TeamSettings() {
2832
const [slug, setSlug] = useState(org?.slug || "");
2933
const [updated, setUpdated] = useState(false);
3034
const updateOrg = useUpdateOrgMutation();
35+
const { data: settings, isLoading } = useOrgSettingsQuery();
36+
const updateTeamSettings = useUpdateOrgSettingsMutation();
37+
38+
const handleUpdateTeamSettings = useCallback(
39+
(newSettings: Partial<OrganizationSettings>) => {
40+
if (!org?.id) {
41+
throw new Error("no organization selected");
42+
}
43+
updateTeamSettings.mutate({
44+
...settings,
45+
...newSettings,
46+
});
47+
},
48+
[updateTeamSettings, org?.id, settings],
49+
);
3150

3251
const close = () => setModal(false);
3352

@@ -88,6 +107,12 @@ export default function TeamSettings() {
88107
<span>{updateOrg.error.message || "unknown error"}</span>
89108
</Alert>
90109
)}
110+
{updateTeamSettings.isError && (
111+
<Alert type="error" closable={true} className="mb-2 max-w-xl rounded-md">
112+
<span>Failed to update organization settings: </span>
113+
<span>{updateTeamSettings.error.message || "unknown error"}</span>
114+
</Alert>
115+
)}
91116
{updated && (
92117
<Alert type="message" closable={true} className="mb-2 max-w-xl rounded-md">
93118
Organization name has been updated.
@@ -119,6 +144,21 @@ export default function TeamSettings() {
119144
>
120145
Update Organization
121146
</Button>
147+
148+
<Heading2 className="pt-12">Collaboration & Sharing</Heading2>
149+
<CheckBox
150+
title={<span>Workspace Sharing</span>}
151+
desc={
152+
<span>
153+
Allow organization members to share running workspaces outside the organization.
154+
</span>
155+
}
156+
checked={!settings?.workspaceSharingDisabled}
157+
onChange={({ target }) =>
158+
handleUpdateTeamSettings({ workspaceSharingDisabled: !target.checked })
159+
}
160+
disabled={isLoading}
161+
/>
122162
</form>
123163

124164
<Heading2 className="pt-12">Delete Organization</Heading2>

components/gitpod-db/src/team-db.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { Team, TeamMemberInfo, TeamMemberRole, TeamMembershipInvite } from "@gitpod/gitpod-protocol";
7+
import {
8+
Team,
9+
TeamMemberInfo,
10+
TeamMemberRole,
11+
TeamMembershipInvite,
12+
OrganizationSettings,
13+
} from "@gitpod/gitpod-protocol";
814
import { DBTeamMembership } from "./typeorm/entity/db-team-membership";
915

1016
export const TeamDB = Symbol("TeamDB");
@@ -32,4 +38,7 @@ export interface TeamDB {
3238
findGenericInviteByTeamId(teamId: string): Promise<TeamMembershipInvite | undefined>;
3339
resetGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
3440
deleteTeam(teamId: string): Promise<void>;
41+
42+
findOrgSettings(teamId: string): Promise<OrganizationSettings | undefined>;
43+
setOrgSettings(teamId: string, settings: Partial<OrganizationSettings>): Promise<void>;
3544
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Copyright (c) 2023 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 { OrganizationSettings } from "@gitpod/gitpod-protocol";
8+
import { Entity, Column, PrimaryColumn } from "typeorm";
9+
import { TypeORM } from "../typeorm";
10+
11+
@Entity()
12+
export class DBOrgSettings implements OrganizationSettings {
13+
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
14+
orgId: string;
15+
16+
@Column({
17+
default: false,
18+
})
19+
workspaceSharingDisabled?: boolean;
20+
21+
@Column()
22+
deleted: boolean;
23+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Copyright (c) 2023 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 { MigrationInterface, QueryRunner } from "typeorm";
8+
9+
export class AddOrganizationSettings1679909342076 implements MigrationInterface {
10+
public async up(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query(
12+
"CREATE TABLE IF NOT EXISTS d_b_org_settings (orgId char(36) NOT NULL, workspaceSharingDisabled tinyint(4) NOT NULL DEFAULT '0', createdAt timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), updatedAt timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), deleted tinyint(4) NOT NULL DEFAULT '0', PRIMARY KEY (orgId)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;",
13+
);
14+
}
15+
16+
public async down(queryRunner: QueryRunner): Promise<void> {
17+
await queryRunner.query("DROP TABLE IF EXISTS d_b_org_settings;");
18+
}
19+
}

components/gitpod-db/src/typeorm/team-db-impl.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { Team, TeamMemberInfo, TeamMemberRole, TeamMembershipInvite, User } from "@gitpod/gitpod-protocol";
7+
import {
8+
Team,
9+
TeamMemberInfo,
10+
TeamMemberRole,
11+
TeamMembershipInvite,
12+
OrganizationSettings,
13+
User,
14+
} from "@gitpod/gitpod-protocol";
815
import { inject, injectable } from "inversify";
916
import { TypeORM } from "./typeorm";
1017
import { Repository } from "typeorm";
@@ -18,6 +25,7 @@ import { DBTeamMembershipInvite } from "./entity/db-team-membership-invite";
1825
import { ResponseError } from "vscode-jsonrpc";
1926
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
2027
import slugify from "slugify";
28+
import { DBOrgSettings } from "./entity/db-team-settings";
2129

2230
@injectable()
2331
export class TeamDBImpl implements TeamDB {
@@ -39,6 +47,10 @@ export class TeamDBImpl implements TeamDB {
3947
return (await this.getEntityManager()).getRepository<DBTeamMembershipInvite>(DBTeamMembershipInvite);
4048
}
4149

50+
protected async getOrgSettingsRepo(): Promise<Repository<DBOrgSettings>> {
51+
return (await this.getEntityManager()).getRepository<DBOrgSettings>(DBOrgSettings);
52+
}
53+
4254
protected async getUserRepo(): Promise<Repository<DBUser>> {
4355
return (await this.getEntityManager()).getRepository<DBUser>(DBUser);
4456
}
@@ -221,6 +233,16 @@ export class TeamDBImpl implements TeamDB {
221233
if (team) {
222234
team.markedDeleted = true;
223235
await teamRepo.save(team);
236+
await this.deleteOrgSettings(teamId);
237+
}
238+
}
239+
240+
private async deleteOrgSettings(orgId: string): Promise<void> {
241+
const orgSettingsRepo = await this.getOrgSettingsRepo();
242+
const orgSettings = await orgSettingsRepo.findOne({ where: { orgId, deleted: false } });
243+
if (orgSettings) {
244+
orgSettings.deleted = true;
245+
orgSettingsRepo.save(orgSettings);
224246
}
225247
}
226248

@@ -339,4 +361,23 @@ export class TeamDBImpl implements TeamDB {
339361
await inviteRepo.save(newInvite);
340362
return newInvite;
341363
}
364+
365+
public async findOrgSettings(orgId: string): Promise<OrganizationSettings | undefined> {
366+
const repo = await this.getOrgSettingsRepo();
367+
return repo.findOne({ where: { orgId, deleted: false }, select: ["orgId", "workspaceSharingDisabled"] });
368+
}
369+
370+
public async setOrgSettings(orgId: string, settings: Partial<OrganizationSettings>): Promise<void> {
371+
const repo = await this.getOrgSettingsRepo();
372+
const team = await repo.findOne({ where: { orgId, deleted: false } });
373+
if (!team) {
374+
await repo.insert({
375+
...settings,
376+
orgId,
377+
});
378+
} else {
379+
team.workspaceSharingDisabled = settings.workspaceSharingDisabled;
380+
repo.save(team);
381+
}
382+
}
342383
}

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
StartPrebuildResult,
4242
PartialProject,
4343
PrebuildEvent,
44+
OrganizationSettings,
4445
} from "./teams-projects-protocol";
4546
import { JsonRpcProxy, JsonRpcServer } from "./messaging/proxy-factory";
4647
import { Disposable, CancellationTokenSource } from "vscode-jsonrpc";
@@ -184,6 +185,8 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
184185
getGenericInvite(teamId: string): Promise<TeamMembershipInvite>;
185186
resetGenericInvite(inviteId: string): Promise<TeamMembershipInvite>;
186187
deleteTeam(teamId: string): Promise<void>;
188+
getOrgSettings(orgId: string): Promise<OrganizationSettings>;
189+
updateOrgSettings(teamId: string, settings: Partial<OrganizationSettings>): Promise<OrganizationSettings>;
187190
createOrgAuthProvider(params: GitpodServer.CreateOrgAuthProviderParams): Promise<AuthProviderEntry>;
188191
updateOrgAuthProvider(params: GitpodServer.UpdateOrgAuthProviderParams): Promise<AuthProviderEntry>;
189192
getOrgAuthProviders(params: GitpodServer.GetOrgAuthProviderParams): Promise<AuthProviderEntry[]>;

components/gitpod-protocol/src/teams-projects-protocol.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,10 @@ export interface Organization {
142142
deleted?: boolean;
143143
}
144144

145+
export interface OrganizationSettings {
146+
workspaceSharingDisabled?: boolean;
147+
}
148+
145149
export type TeamMemberRole = OrgMemberRole;
146150
export type OrgMemberRole = "owner" | "member";
147151

components/server/ee/src/workspace/gitpod-server-impl.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,16 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
464464
const workspace = await this.internalGetWorkspace(workspaceId, this.workspaceDb.trace(ctx));
465465
await this.guardAccess({ kind: "workspace", subject: workspace }, "update");
466466

467+
if (level != "owner" && workspace.organizationId) {
468+
const settings = await this.teamDB.findOrgSettings(workspace.organizationId);
469+
if (settings?.workspaceSharingDisabled) {
470+
throw new ResponseError(
471+
ErrorCodes.PERMISSION_DENIED,
472+
"An Organization Owner has disabled workspace sharing for workspaces in this Organization. ",
473+
);
474+
}
475+
}
476+
467477
const instance = await this.workspaceDb.trace(ctx).findRunningInstance(workspaceId);
468478
if (instance) {
469479
await this.guardAccess({ kind: "workspaceInstance", subject: instance, workspace: workspace }, "update");

components/server/src/auth/rate-limiter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ const defaultFunctions: FunctionsConfig = {
113113
getGenericInvite: { group: "default", points: 1 },
114114
resetGenericInvite: { group: "default", points: 1 },
115115
deleteTeam: { group: "default", points: 1 },
116+
getOrgSettings: { group: "default", points: 1 },
117+
updateOrgSettings: { group: "default", points: 1 },
116118
getProviderRepositoriesForUser: { group: "default", points: 1 },
117119
createProject: { group: "default", points: 1 },
118120
getTeamProjects: { group: "default", points: 1 },

components/server/src/workspace/gitpod-server-impl.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ import { InvalidGitpodYMLError } from "./config-provider";
159159
import { ProjectsService } from "../projects/projects-service";
160160
import { LocalMessageBroker } from "../messaging/local-message-broker";
161161
import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol";
162-
import { PartialProject } from "@gitpod/gitpod-protocol/lib/teams-projects-protocol";
162+
import { PartialProject, OrganizationSettings } from "@gitpod/gitpod-protocol/lib/teams-projects-protocol";
163163
import { ClientMetadata } from "../websocket/websocket-connection-manager";
164164
import { ConfigurationService } from "../config/configuration-service";
165165
import {
@@ -2486,6 +2486,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
24862486
});
24872487
});
24882488

2489+
// TODO: delete setting
24892490
await this.teamDB.deleteTeam(teamId);
24902491
await this.onTeamDeleted(teamId);
24912492

@@ -2498,6 +2499,27 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
24982499
});
24992500
}
25002501

2502+
async getOrgSettings(ctx: TraceContextWithSpan, orgId: string): Promise<OrganizationSettings> {
2503+
const user = this.checkAndBlockUser("getOrgSettings");
2504+
traceAPIParams(ctx, { orgId, userId: user.id });
2505+
await this.guardTeamOperation(orgId, "get", "org_write");
2506+
const settings = await this.teamDB.findOrgSettings(orgId);
2507+
// TODO: make a default in protocol
2508+
return settings ?? { workspaceSharingDisabled: false };
2509+
}
2510+
2511+
async updateOrgSettings(
2512+
ctx: TraceContextWithSpan,
2513+
orgId: string,
2514+
settings: Partial<OrganizationSettings>,
2515+
): Promise<OrganizationSettings> {
2516+
const user = this.checkAndBlockUser("updateOrgSettings");
2517+
traceAPIParams(ctx, { orgId, userId: user.id });
2518+
await this.guardTeamOperation(orgId, "update", "org_write");
2519+
await this.teamDB.setOrgSettings(orgId, settings);
2520+
return (await this.teamDB.findOrgSettings(orgId))!;
2521+
}
2522+
25012523
public async getTeamProjects(ctx: TraceContext, teamId: string): Promise<Project[]> {
25022524
traceAPIParams(ctx, { teamId });
25032525

0 commit comments

Comments
 (0)