feat: add sharing add command to the CLI (#19576)

Adds a `sharing add` command for sharing Workspaces with other users and
groups.

The command allows sharing with multiple users, and groups within one
command as well as specifying the role (`use`, or `admin`) defaulting to
`use` if none is specified.

In the current implementation when the command completes we show the
user the current state of the workspace ACL.

```
$ coder sharing add apricot-catfish-86 --user=member:admin --group=contractors:use
USER    GROUP        ROLE
member  -            admin
member  contractors  use
```

If a user is a part of multiple groups, or the workspace has been
individually shared with them they will show up multiple times. Although
this is a bit confusing at first glance it's important to be able to
tell what the maximum role a user may have, and via what ACL they have
it.

---

One piece of UX to consider is that in order to be able to share a
Workspace with a user they must have a role that can read that user. In
the tests we give the user the `ScopedRoleOrgAuditor` role.

Closes
[coder/internal#859](https://github.com/coder/internal/issues/859)
This commit is contained in:
Brett Kolodny
2025-09-04 17:37:16 -04:00
committed by GitHub
parent a78d65c8b9
commit 909acbc833
4 changed files with 609 additions and 0 deletions
+207
View File
@@ -0,0 +1,207 @@
package cli_test
import (
"bytes"
"context"
"fmt"
"slices"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
func TestSharingShareEnterprise(t *testing.T) {
t.Parallel()
dv := coderdtest.DeploymentValues(t)
dv.Experiments = []string{string(codersdk.ExperimentWorkspaceSharing)}
t.Run("ShareWithGroups_Simple", func(t *testing.T) {
t.Parallel()
var (
client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID))
workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: workspaceOwner.ID,
OrganizationID: orgOwner.OrganizationID,
}).Do().Workspace
_, orgMember = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID)
)
ctx := testutil.Context(t, testutil.WaitMedium)
group, err := createGroupWithMembers(ctx, client, orgOwner.OrganizationID, "new-group", []uuid.UUID{orgMember.ID})
require.NoError(t, err)
inv, root := clitest.New(t, "sharing", "share", workspace.Name, "--org", orgOwner.OrganizationID.String(), "--group", group.Name)
clitest.SetupConfig(t, workspaceOwnerClient, root)
out := bytes.NewBuffer(nil)
inv.Stdout = out
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
acl, err := workspaceOwnerClient.WorkspaceACL(inv.Context(), workspace.ID)
require.NoError(t, err)
assert.Len(t, acl.Groups, 1)
assert.Equal(t, acl.Groups[0].Group.ID, group.ID)
assert.Equal(t, acl.Groups[0].Role, codersdk.WorkspaceRoleUse)
found := false
for _, line := range strings.Split(out.String(), "\n") {
found = strings.Contains(line, group.Name) && strings.Contains(line, string(codersdk.WorkspaceRoleUse))
if found {
break
}
}
assert.True(t, found, "Expected to find group name %s and role %s in output: %s", group.Name, codersdk.WorkspaceRoleUse, out.String())
})
t.Run("ShareWithGroups_Multiple", func(t *testing.T) {
t.Parallel()
var (
client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID))
workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: workspaceOwner.ID,
OrganizationID: orgOwner.OrganizationID,
}).Do().Workspace
_, wibbleMember = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID)
_, wobbleMember = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID)
)
ctx := testutil.Context(t, testutil.WaitMedium)
wibbleGroup, err := createGroupWithMembers(ctx, client, orgOwner.OrganizationID, "wibble", []uuid.UUID{wibbleMember.ID})
require.NoError(t, err)
wobbleGroup, err := createGroupWithMembers(ctx, client, orgOwner.OrganizationID, "wobble", []uuid.UUID{wobbleMember.ID})
require.NoError(t, err)
inv, root := clitest.New(t, "sharing", "share", workspace.Name, "--org", orgOwner.OrganizationID.String(),
fmt.Sprintf("--group=%s,%s", wibbleGroup.Name, wobbleGroup.Name))
clitest.SetupConfig(t, workspaceOwnerClient, root)
out := bytes.NewBuffer(nil)
inv.Stdout = out
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
acl, err := workspaceOwnerClient.WorkspaceACL(inv.Context(), workspace.ID)
require.NoError(t, err)
assert.Len(t, acl.Groups, 2)
type workspaceGroup []codersdk.WorkspaceGroup
assert.NotEqual(t, -1, slices.IndexFunc(workspaceGroup(acl.Groups), func(g codersdk.WorkspaceGroup) bool {
return g.Group.ID == wibbleGroup.ID
}))
assert.NotEqual(t, -1, slices.IndexFunc(workspaceGroup(acl.Groups), func(g codersdk.WorkspaceGroup) bool {
return g.Group.ID == wobbleGroup.ID
}))
t.Run("ShareWithGroups_Role", func(t *testing.T) {
t.Parallel()
var (
client, db, orgOwner = coderdenttest.NewWithDatabase(t, &coderdenttest.Options{
Options: &coderdtest.Options{
DeploymentValues: dv,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureTemplateRBAC: 1,
},
},
})
workspaceOwnerClient, workspaceOwner = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID, rbac.ScopedRoleOrgAuditor(orgOwner.OrganizationID))
workspace = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
OwnerID: workspaceOwner.ID,
OrganizationID: orgOwner.OrganizationID,
}).Do().Workspace
_, orgMember = coderdtest.CreateAnotherUser(t, client, orgOwner.OrganizationID)
)
ctx := testutil.Context(t, testutil.WaitMedium)
group, err := createGroupWithMembers(ctx, client, orgOwner.OrganizationID, "new-group", []uuid.UUID{orgMember.ID})
require.NoError(t, err)
inv, root := clitest.New(t, "sharing", "share", workspace.Name, "--org", orgOwner.OrganizationID.String(), "--group", fmt.Sprintf("%s:admin", group.Name))
clitest.SetupConfig(t, workspaceOwnerClient, root)
out := bytes.NewBuffer(nil)
inv.Stdout = out
err = inv.WithContext(ctx).Run()
require.NoError(t, err)
acl, err := workspaceOwnerClient.WorkspaceACL(inv.Context(), workspace.ID)
require.NoError(t, err)
assert.Len(t, acl.Groups, 1)
assert.Equal(t, acl.Groups[0].Group.ID, group.ID)
assert.Equal(t, acl.Groups[0].Role, codersdk.WorkspaceRoleAdmin)
found := false
for _, line := range strings.Split(out.String(), "\n") {
found = strings.Contains(line, group.Name) && strings.Contains(line, string(codersdk.WorkspaceRoleAdmin))
if found {
break
}
}
assert.True(t, found, "Expected to find group name %s and role %s in output: %s", group.Name, codersdk.WorkspaceRoleAdmin, out.String())
})
})
}
func createGroupWithMembers(ctx context.Context, client *codersdk.Client, orgID uuid.UUID, name string, memberIDs []uuid.UUID) (codersdk.Group, error) {
group, err := client.CreateGroup(ctx, orgID, codersdk.CreateGroupRequest{
Name: name,
DisplayName: name,
})
if err != nil {
return codersdk.Group{}, err
}
ids := make([]string, len(memberIDs))
for i, id := range memberIDs {
ids[i] = id.String()
}
return client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{
AddUsers: ids,
})
}