mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: allow restricting sharing to service accounts (#23327)
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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({
|
||||
|
||||
Generated
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user