From d2afda8191083cbe5f4bc4da70e5595c44993f8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kayla=20=E3=81=AF=E3=81=AA?= Date: Mon, 23 Mar 2026 13:18:49 -0600 Subject: [PATCH] feat: allow restricting sharing to service accounts (#23327) --- codersdk/workspacesharing.go | 2 +- docs/user-guides/shared-workspaces.md | 10 ++ site/src/api/queries/organizations.ts | 3 +- site/src/api/typesGenerated.ts | 2 +- .../DisableWorkspaceSharingDialog.tsx | 35 ++++-- .../OrganizationSettingsPage.tsx | 30 +++-- .../OrganizationSettingsPageView.stories.tsx | 46 +++++-- .../OrganizationSettingsPageView.tsx | 112 ++++++++++++++---- 8 files changed, 182 insertions(+), 58 deletions(-) diff --git a/codersdk/workspacesharing.go b/codersdk/workspacesharing.go index 6f64af80db..b4e9dc6622 100644 --- a/codersdk/workspacesharing.go +++ b/codersdk/workspacesharing.go @@ -38,7 +38,7 @@ type UpdateWorkspaceSharingSettingsRequest struct { // SharingDisabled is deprecated and left for backward compatibility // purposes. // Deprecated: use `ShareableWorkspaceOwners` instead - SharingDisabled bool `json:"sharing_disabled"` + SharingDisabled bool `json:"sharing_disabled,omitempty"` // ShareableWorkspaceOwners controls whose workspaces can be shared // within the organization. ShareableWorkspaceOwners ShareableWorkspaceOwners `json:"shareable_workspace_owners,omitempty" enums:"none,everyone,service_accounts"` diff --git a/docs/user-guides/shared-workspaces.md b/docs/user-guides/shared-workspaces.md index 3ba78fa408..9da5f5fa08 100644 --- a/docs/user-guides/shared-workspaces.md +++ b/docs/user-guides/shared-workspaces.md @@ -112,3 +112,13 @@ To allow other users to access workspace apps, configure subdomain-based access: Subdomain-based apps run in an isolated browser security context, so Coder allows other users to access them without additional configuration. + +### Policies + +There are several sharing policy levels that can be selected on a per-organization basis. + +- **Everyone** – Anybody can share their workspace with any individual or group in the same organization. +- **Service Accounts Only** – Only workspaces owned by service accounts can be shared with any individual or group in the same organization. +- **Disabled** – Workspaces within the organization cannot be shared. + +The **Disabled** policy can also be applied to the entire deployment by [setting the `CODER_DISABLE_WORKSPACE_SHARING` environment variable, or by using the corresponding command argument or config value](https://coder.com/docs/reference/cli/server#--disable-workspace-sharing). diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 03e0d1e94a..af6ea6a5e6 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -11,6 +11,7 @@ import type { PaginatedMembersResponse, RoleSyncSettings, UpdateOrganizationRequest, + UpdateWorkspaceSharingSettingsRequest, } from "api/typesGenerated"; import type { MetadataState } from "hooks/useEmbeddedMetadata"; import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; @@ -272,7 +273,7 @@ export const patchWorkspaceSharingSettings = ( queryClient: QueryClient, ) => { return { - mutationFn: (request: { sharing_disabled: boolean }) => + mutationFn: (request: UpdateWorkspaceSharingSettingsRequest) => API.patchWorkspaceSharingSettings(organization, request), onSuccess: async () => await queryClient.invalidateQueries({ diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6a421e5161..899db4f084 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -7236,7 +7236,7 @@ export interface UpdateWorkspaceSharingSettingsRequest { * purposes. * Deprecated: use `ShareableWorkspaceOwners` instead */ - readonly sharing_disabled: boolean; + readonly sharing_disabled?: boolean; /** * ShareableWorkspaceOwners controls whose workspaces can be shared * within the organization. diff --git a/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx b/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx index f45e18544e..4bb1e5105a 100644 --- a/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx +++ b/site/src/pages/OrganizationSettingsPage/DisableWorkspaceSharingDialog.tsx @@ -1,4 +1,5 @@ import { API } from "api/api"; +import type { ShareableWorkspaceOwners } from "api/typesGenerated"; import { Button } from "components/Button/Button"; import { Dialog, @@ -16,6 +17,7 @@ import { useQuery } from "react-query"; interface DisableWorkspaceSharingDialogProps { isOpen: boolean; organizationId: string; + newSetting: ShareableWorkspaceOwners; onConfirm: () => void; onCancel: () => void; isLoading?: boolean; @@ -23,14 +25,21 @@ interface DisableWorkspaceSharingDialogProps { export const DisableWorkspaceSharingDialog: FC< DisableWorkspaceSharingDialogProps -> = ({ isOpen, organizationId, onConfirm, onCancel, isLoading }) => { - // Fetch the count of shared workspaces in this organization +> = ({ + isOpen, + organizationId, + newSetting: targetValue, + onConfirm, + onCancel, + isLoading, +}) => { + // Fetch the count of shared workspaces in this organization. const sharedWorkspacesQuery = useQuery({ queryKey: ["workspaces", organizationId, "shared", "count"], queryFn: async () => { const response = await API.getWorkspaces({ q: `organization:${organizationId} shared:true`, - limit: 0, // Avoid fetching workspaces as we only need the count + limit: 0, // Avoid fetching workspaces as we only need the count. }); return response.count; }, @@ -39,21 +48,23 @@ export const DisableWorkspaceSharingDialog: FC< const sharedCount = sharedWorkspacesQuery.data ?? 0; const isLoadingCount = sharedWorkspacesQuery.isLoading; + const isRestrictingToServiceAccounts = targetValue === "service_accounts"; return ( !open && onCancel()}> - Disable workspace sharing + + {isRestrictingToServiceAccounts + ? "Restrict sharing to service accounts" + : "Disable workspace sharing"} +

- Disabling workspace sharing will{" "} - - immediately remove - {" "} - all existing workspace sharing permissions for all users in this - organization. + {isRestrictingToServiceAccounts + ? "Restricting workspace sharing to service accounts only will immediately unshare any workspaces currently shared by non-service accounts." + : "Disabling workspace sharing will immediately remove all existing workspace sharing permissions for all users in this organization."}

{isLoadingCount ? ( @@ -88,7 +99,9 @@ export const DisableWorkspaceSharingDialog: FC< disabled={isLoading} > - Disable sharing + {isRestrictingToServiceAccounts + ? "Restrict sharing" + : "Disable sharing"} diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx index fdd82551dc..1a93a702cc 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPage.tsx @@ -5,6 +5,7 @@ import { updateOrganization, workspaceSharingSettings, } from "api/queries/organizations"; +import type { ShareableWorkspaceOwners } from "api/typesGenerated"; import { EmptyState } from "components/EmptyState/EmptyState"; import { useOrganizationSettings } from "modules/management/OrganizationSettingsLayout"; import { RequirePermission } from "modules/permissions/RequirePermission"; @@ -15,6 +16,12 @@ import { toast } from "sonner"; import { pageTitle } from "utils/page"; import { OrganizationSettingsPageView } from "./OrganizationSettingsPageView"; +const sharingUpdatedToastLabels: Record = { + none: "Workspace sharing disabled.", + service_accounts: "Workspace sharing restricted to service accounts.", + everyone: "Workspace sharing enabled for all users.", +}; + const OrganizationSettingsPage: FC = () => { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -58,19 +65,18 @@ const OrganizationSettingsPage: FC = () => { const error = updateOrganizationMutation.error ?? deleteOrganizationMutation.error; - const handleToggleWorkspaceSharing = async (enabled: boolean) => { + const handleChangeShareableOwners = async ( + value: ShareableWorkspaceOwners, + ) => { const mutation = patchSharingSettingsMutation.mutateAsync({ - sharing_disabled: !enabled, + shareable_workspace_owners: value, }); + toast.promise(mutation, { - loading: "Toggling workspace sharing...", - success: enabled - ? "Workspace sharing enabled." - : "Workspace sharing disabled.", + loading: "Updating workspace sharing settings...", + success: sharingUpdatedToastLabels[value], error: (error) => ({ - message: enabled - ? "Failed to enable workspace sharing." - : "Failed to disable workspace sharing.", + message: "Failed to update workspace sharing settings.", description: getErrorDetail(error), }), }); @@ -115,10 +121,10 @@ const OrganizationSettingsPage: FC = () => { workspaceSharingGloballyDisabled={ sharingSettingsQuery.data?.sharing_globally_disabled } - workspaceSharingEnabled={ - !(sharingSettingsQuery.data?.sharing_disabled ?? false) + shareableWorkspaceOwners={ + sharingSettingsQuery.data?.shareable_workspace_owners ?? "none" } - onToggleWorkspaceSharing={handleToggleWorkspaceSharing} + onChangeShareableOwners={handleChangeShareableOwners} isTogglingWorkspaceSharing={patchSharingSettingsMutation.isPending} /> diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx index 650b2d845b..c578e07c4a 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.stories.tsx @@ -29,24 +29,39 @@ export const DefaultOrg: Story = { }, }; -export const WithWorkspaceSharingEnabled: Story = { +export const SharingDisabled: Story = { args: { - workspaceSharingEnabled: true, - onToggleWorkspaceSharing: action("onToggleWorkspaceSharing"), + shareableWorkspaceOwners: "none", + onChangeShareableOwners: action("onChangeShareableOwners"), }, }; -export const WithWorkspaceSharingDisabled: Story = { +export const SharingServiceAccountsOnly: Story = { args: { - workspaceSharingEnabled: false, - onToggleWorkspaceSharing: action("onToggleWorkspaceSharing"), + shareableWorkspaceOwners: "service_accounts", + onChangeShareableOwners: action("onChangeShareableOwners"), + }, +}; + +export const SharingEveryone: Story = { + args: { + shareableWorkspaceOwners: "everyone", + onChangeShareableOwners: action("onChangeShareableOwners"), + }, +}; + +export const SharingGloballyDisabled: Story = { + args: { + shareableWorkspaceOwners: "none", + workspaceSharingGloballyDisabled: true, + onChangeShareableOwners: action("onChangeShareableOwners"), }, }; export const DisableSharingDialog: Story = { args: { - workspaceSharingEnabled: true, - onToggleWorkspaceSharing: action("onToggleWorkspaceSharing"), + shareableWorkspaceOwners: "everyone", + onChangeShareableOwners: action("onChangeShareableOwners"), }, play: async ({ canvasElement }) => { const user = userEvent.setup(); @@ -57,3 +72,18 @@ export const DisableSharingDialog: Story = { await user.click(checkbox); }, }; + +export const RestrictToServiceAccountsDialog: Story = { + args: { + shareableWorkspaceOwners: "everyone", + onChangeShareableOwners: action("onChangeShareableOwners"), + }, + play: async ({ canvasElement }) => { + const user = userEvent.setup(); + const body = within(canvasElement.ownerDocument.body); + const radio = await body.findByRole("radio", { + name: /only service accounts/i, + }); + await user.click(radio); + }, +}; diff --git a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx index e34b4ee5a4..ce074779d3 100644 --- a/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx +++ b/site/src/pages/OrganizationSettingsPage/OrganizationSettingsPageView.tsx @@ -2,6 +2,7 @@ import TextField from "@mui/material/TextField"; import { isApiValidationError } from "api/errors"; import type { Organization, + ShareableWorkspaceOwners, UpdateOrganizationRequest, } from "api/typesGenerated"; import { Alert, AlertTitle } from "components/Alert/Alert"; @@ -17,6 +18,7 @@ import { HorizontalForm, } from "components/Form/Form"; import { IconField } from "components/IconField/IconField"; +import { RadioGroup, RadioGroupItem } from "components/RadioGroup/RadioGroup"; import { SettingsHeader, SettingsHeaderTitle, @@ -52,8 +54,8 @@ interface OrganizationSettingsPageViewProps { onSubmit: (values: UpdateOrganizationRequest) => Promise; onDeleteOrganization: () => void; workspaceSharingGloballyDisabled?: boolean; - workspaceSharingEnabled: boolean; - onToggleWorkspaceSharing: (enabled: boolean) => void; + shareableWorkspaceOwners: ShareableWorkspaceOwners; + onChangeShareableOwners: (value: ShareableWorkspaceOwners) => void; isTogglingWorkspaceSharing: boolean; } @@ -65,8 +67,8 @@ export const OrganizationSettingsPageView: FC< onSubmit, onDeleteOrganization, workspaceSharingGloballyDisabled, - workspaceSharingEnabled, - onToggleWorkspaceSharing, + shareableWorkspaceOwners, + onChangeShareableOwners, isTogglingWorkspaceSharing, }) => { const form = useFormik({ @@ -83,8 +85,8 @@ export const OrganizationSettingsPageView: FC< const getFieldHelpers = getFormHelpers(form, error); const [isDeleting, setIsDeleting] = useState(false); - const [isDisableSharingDialogOpen, setIsDisableSharingDialogOpen] = - useState(false); + const [pendingSharingChange, setPendingSharingChange] = + useState(null); return (
@@ -148,7 +150,7 @@ export const OrganizationSettingsPageView: FC< - {onToggleWorkspaceSharing && ( + {onChangeShareableOwners && ( { if (checked) { - onToggleWorkspaceSharing(true); + // Default to service_accounts when enabling. + onChangeShareableOwners("service_accounts"); } else { - setIsDisableSharingDialogOpen(true); + setPendingSharingChange("none"); } }} /> -
- -
- When enabled, workspace owners can share their workspaces - with other users in this organization. +
+
+ +
+ When enabled, workspace owners can share their workspaces + with other users in this organization. +
+ {shareableWorkspaceOwners !== "none" && + !workspaceSharingGloballyDisabled && ( + { + const newValue = value as ShareableWorkspaceOwners; + // Transitioning from "everyone" to "service_accounts" + // is destructive, so show the warning dialog. + // Otherwise, just change. + if ( + shareableWorkspaceOwners === "everyone" && + newValue === "service_accounts" + ) { + setPendingSharingChange("service_accounts"); + } else { + onChangeShareableOwners(newValue); + } + }} + disabled={isTogglingWorkspaceSharing} + className="ml-1" + > +
+ +
+ + + Service accounts are non-login accounts typically + used for automation, CI/CD pipelines, and + centrally-managed shared environments. + +
+
+
+ + +
+
+ )}
@@ -238,13 +299,16 @@ export const OrganizationSettingsPageView: FC< /> { - await onToggleWorkspaceSharing?.(false); - setIsDisableSharingDialogOpen(false); + if (pendingSharingChange !== null) { + await onChangeShareableOwners(pendingSharingChange); + } + setPendingSharingChange(null); }} - onCancel={() => setIsDisableSharingDialogOpen(false)} + onCancel={() => setPendingSharingChange(null)} isLoading={isTogglingWorkspaceSharing} />