feat: allow restricting sharing to service accounts (#23327)

This commit is contained in:
Kayla はな
2026-03-23 13:18:49 -06:00
committed by GitHub
parent c389c2bc5c
commit d2afda8191
8 changed files with 182 additions and 58 deletions
+1 -1
View File
@@ -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"`
+10
View File
@@ -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).
+2 -1
View File
@@ -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({
+1 -1
View File
@@ -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.
@@ -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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
<DialogContent variant="destructive" className="max-w-xl">
<DialogHeader>
<DialogTitle>Disable workspace sharing</DialogTitle>
<DialogTitle>
{isRestrictingToServiceAccounts
? "Restrict sharing to service accounts"
: "Disable workspace sharing"}
</DialogTitle>
<DialogDescription asChild>
<div className="flex flex-col gap-4">
<p>
Disabling workspace sharing will{" "}
<strong className="text-content-primary">
immediately remove
</strong>{" "}
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."}
</p>
{isLoadingCount ? (
<Skeleton className="h-6 w-4/5" />
@@ -88,7 +99,9 @@ export const DisableWorkspaceSharingDialog: FC<
disabled={isLoading}
>
<Spinner loading={isLoading} />
Disable sharing
{isRestrictingToServiceAccounts
? "Restrict sharing"
: "Disable sharing"}
</Button>
</DialogFooter>
</DialogContent>
@@ -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<ShareableWorkspaceOwners, string> = {
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}
/>
</>
@@ -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);
},
};
@@ -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<void>;
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<UpdateOrganizationRequest>({
@@ -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<ShareableWorkspaceOwners | null>(null);
return (
<div className="w-full max-w-screen-2xl pb-10">
@@ -148,7 +150,7 @@ export const OrganizationSettingsPageView: FC<
</FormFooter>
</HorizontalForm>
{onToggleWorkspaceSharing && (
{onChangeShareableOwners && (
<HorizontalContainer className="mt-12">
<HorizontalSection
title={
@@ -172,7 +174,8 @@ export const OrganizationSettingsPageView: FC<
<Checkbox
id="workspace-sharing"
checked={
!workspaceSharingGloballyDisabled && workspaceSharingEnabled
!workspaceSharingGloballyDisabled &&
shareableWorkspaceOwners !== "none"
}
disabled={
workspaceSharingGloballyDisabled ||
@@ -180,23 +183,81 @@ export const OrganizationSettingsPageView: FC<
}
onCheckedChange={(checked) => {
if (checked) {
onToggleWorkspaceSharing(true);
// Default to service_accounts when enabling.
onChangeShareableOwners("service_accounts");
} else {
setIsDisableSharingDialogOpen(true);
setPendingSharingChange("none");
}
}}
/>
<div className="flex flex-col">
<label
htmlFor="workspace-sharing"
className="text-sm cursor-pointer"
>
Allow workspace sharing
</label>
<div className="text-sm text-content-secondary">
When enabled, workspace owners can share their workspaces
with other users in this organization.
<div className="flex flex-col gap-3">
<div className="flex flex-col">
<label
htmlFor="workspace-sharing"
className="text-sm cursor-pointer"
>
Allow workspace sharing
</label>
<div className="text-xs text-content-secondary">
When enabled, workspace owners can share their workspaces
with other users in this organization.
</div>
</div>
{shareableWorkspaceOwners !== "none" &&
!workspaceSharingGloballyDisabled && (
<RadioGroup
value={shareableWorkspaceOwners}
onValueChange={(value) => {
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"
>
<div className="flex items-start gap-2">
<RadioGroupItem
value="service_accounts"
id="sharing-service-accounts"
className="mt-0.5"
/>
<div className="flex flex-col">
<label
htmlFor="sharing-service-accounts"
className="text-sm cursor-pointer"
>
Only service accounts can share workspaces
</label>
<span className="text-xs text-content-secondary">
Service accounts are non-login accounts typically
used for automation, CI/CD pipelines, and
centrally-managed shared environments.
</span>
</div>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem
value="everyone"
id="sharing-everyone"
/>
<label
htmlFor="sharing-everyone"
className="text-sm cursor-pointer"
>
All members can share workspaces
</label>
</div>
</RadioGroup>
)}
</div>
</div>
</div>
@@ -238,13 +299,16 @@ export const OrganizationSettingsPageView: FC<
/>
<DisableWorkspaceSharingDialog
isOpen={isDisableSharingDialogOpen}
isOpen={pendingSharingChange !== null}
organizationId={organization.id}
newSetting={pendingSharingChange ?? "none"}
onConfirm={async () => {
await onToggleWorkspaceSharing?.(false);
setIsDisableSharingDialogOpen(false);
if (pendingSharingChange !== null) {
await onChangeShareableOwners(pendingSharingChange);
}
setPendingSharingChange(null);
}}
onCancel={() => setIsDisableSharingDialogOpen(false)}
onCancel={() => setPendingSharingChange(null)}
isLoading={isTogglingWorkspaceSharing}
/>
</div>