From 0095624c938cd5bbf2b3699dbc9ae82f37f389fc Mon Sep 17 00:00:00 2001 From: Drew McLean Date: Tue, 11 Aug 2026 13:00:40 -0400 Subject: [PATCH] Add support for projects in user group commands --- temporalcloudcli/commands.gen.go | 154 +++++++- temporalcloudcli/commands.project.go | 4 + temporalcloudcli/commands.project_test.go | 65 ++++ temporalcloudcli/commands.user.go | 28 ++ temporalcloudcli/commands.user_group.go | 139 ++++++- temporalcloudcli/commands.user_group_test.go | 390 ++++++++++++++++++- temporalcloudcli/commands.yml | 135 ++++++- 7 files changed, 896 insertions(+), 19 deletions(-) diff --git a/temporalcloudcli/commands.gen.go b/temporalcloudcli/commands.gen.go index 40470e1..78adfa9 100644 --- a/temporalcloudcli/commands.gen.go +++ b/temporalcloudcli/commands.gen.go @@ -4831,6 +4831,7 @@ func NewCloudProjectCommand(cctx *CommandContext, parent *CloudCommand) *CloudPr s.Command.AddCommand(&NewCloudProjectListCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudProjectUpdateCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudProjectUserCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudProjectUserGroupCommand(cctx, &s).Command) return &s } @@ -5129,6 +5130,56 @@ func NewCloudProjectUserListCommand(cctx *CommandContext, parent *CloudProjectUs return &s } +type CloudProjectUserGroupCommand struct { + Parent *CloudProjectCommand + Command cobra.Command +} + +func NewCloudProjectUserGroupCommand(cctx *CommandContext, parent *CloudProjectCommand) *CloudProjectUserGroupCommand { + var s CloudProjectUserGroupCommand + s.Parent = parent + s.Command.Use = "user-group" + s.Command.Short = "Inspect user groups with access to a project" + s.Command.Long = "Commands for inspecting the user groups that have access to a Temporal\nCloud project." + s.Command.Args = cobra.NoArgs + s.Command.AddCommand(&NewCloudProjectUserGroupListCommand(cctx, &s).Command) + return &s +} + +type CloudProjectUserGroupListCommand struct { + Parent *CloudProjectUserGroupCommand + Command cobra.Command + ClientOptions + ProjectId string + PageSize int + PageToken string +} + +func NewCloudProjectUserGroupListCommand(cctx *CommandContext, parent *CloudProjectUserGroupCommand) *CloudProjectUserGroupListCommand { + var s CloudProjectUserGroupListCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "list [flags]" + s.Command.Short = "List user groups with access to a project" + if hasHighlighting { + s.Command.Long = "List the user groups that have access to a Temporal Cloud project,\nincluding both directly-assigned and inherited access.\n\nExample:\n\n\x1b[1mtemporal cloud project user-group list --project-id my-project-id\x1b[0m" + } else { + s.Command.Long = "List the user groups that have access to a Temporal Cloud project,\nincluding both directly-assigned and inherited access.\n\nExample:\n\n```\ntemporal cloud project user-group list --project-id my-project-id\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "project-id") + s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Number of user groups to return per page. Use for paginated results.") + s.Command.Flags().StringVar(&s.PageToken, "page-token", "", "Token for retrieving the next page of results in a paginated list.") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type CloudRegionCommand struct { Parent *CloudCommand Command cobra.Command @@ -5958,9 +6009,11 @@ func NewCloudUserGroupCommand(cctx *CommandContext, parent *CloudCommand) *Cloud s.Command.AddCommand(&NewCloudUserGroupGetCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudUserGroupListCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudUserGroupMembersCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudUserGroupRemoveProjectAccessCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudUserGroupSetAccountRoleCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudUserGroupSetCustomRolesCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudUserGroupSetNamespacePermissionsCommand(cctx, &s).Command) + s.Command.AddCommand(&NewCloudUserGroupSetProjectAccessCommand(cctx, &s).Command) s.Command.AddCommand(&NewCloudUserGroupUpdateCommand(cctx, &s).Command) return &s } @@ -6009,6 +6062,7 @@ type CloudUserGroupCreateCloudGroupCommand struct { DisplayName string AccountRole string NamespaceAccess []string + ProjectAccess []string CustomRole []string } @@ -6019,15 +6073,16 @@ func NewCloudUserGroupCreateCloudGroupCommand(cctx *CommandContext, parent *Clou s.Command.Use = "create-cloud-group [flags]" s.Command.Short = "Create a Temporal Cloud-managed user group" if hasHighlighting { - s.Command.Long = "Create a new Temporal Cloud-managed user group. Members can be managed\nusing the add-member and remove-member commands.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\n\nExample:\n\n\x1b[1mtemporal cloud user-group create-cloud-group --display-name \"Engineering\" \\\n --account-role developer \\\n --namespace-access my-namespace.my-account=write\x1b[0m" + s.Command.Long = "Create a new Temporal Cloud-managed user group. Members can be managed\nusing the add-member and remove-member commands.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\nProject access format: 'project-id=role' where role is one of: admin, write, read, list, contribute, member.\n\nExample:\n\n\x1b[1mtemporal cloud user-group create-cloud-group --display-name \"Engineering\" \\\n --account-role developer \\\n --namespace-access my-namespace.my-account=write \\\n --project-access my-project-id=write\x1b[0m" } else { - s.Command.Long = "Create a new Temporal Cloud-managed user group. Members can be managed\nusing the add-member and remove-member commands.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\n\nExample:\n\n```\ntemporal cloud user-group create-cloud-group --display-name \"Engineering\" \\\n --account-role developer \\\n --namespace-access my-namespace.my-account=write\n```" + s.Command.Long = "Create a new Temporal Cloud-managed user group. Members can be managed\nusing the add-member and remove-member commands.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\nProject access format: 'project-id=role' where role is one of: admin, write, read, list, contribute, member.\n\nExample:\n\n```\ntemporal cloud user-group create-cloud-group --display-name \"Engineering\" \\\n --account-role developer \\\n --namespace-access my-namespace.my-account=write \\\n --project-access my-project-id=write\n```" } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.DisplayName, "display-name", "", "The display name of the user group. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "display-name") s.Command.Flags().StringVar(&s.AccountRole, "account-role", "", "The account-level role to assign. Valid values: owner, admin, developer, finance-admin, read, metrics-read.") s.Command.Flags().StringArrayVar(&s.NamespaceAccess, "namespace-access", nil, "Namespace access to grant, in the format 'namespace=permission'. Permission must be one of: admin, write, read. Can be repeated.") + s.Command.Flags().StringArrayVar(&s.ProjectAccess, "project-access", nil, "Project access to grant, in the format 'project-id=role'. Role must be one of: admin, write, read, list, contribute, member. Can be repeated.") s.Command.Flags().StringArrayVar(&s.CustomRole, "custom-role", nil, "Custom role ID to assign. Repeat to assign multiple.") s.ClientOptions.BuildFlags(s.Command.Flags()) s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) @@ -6048,6 +6103,7 @@ type CloudUserGroupCreateGoogleGroupCommand struct { GoogleGroupEmail string AccountRole string NamespaceAccess []string + ProjectAccess []string CustomRole []string } @@ -6058,9 +6114,9 @@ func NewCloudUserGroupCreateGoogleGroupCommand(cctx *CommandContext, parent *Clo s.Command.Use = "create-google-group [flags]" s.Command.Short = "Create a Google-group-backed user group" if hasHighlighting { - s.Command.Long = "Create a new user group backed by a Google Group. Members are managed\nvia the Google Group itself.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\n\nExample:\n\n\x1b[1mtemporal cloud user-group create-google-group --display-name \"Platform\" \\\n --google-group-email platform@example.com \\\n --account-role developer\x1b[0m" + s.Command.Long = "Create a new user group backed by a Google Group. Members are managed\nvia the Google Group itself.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\nProject access format: 'project-id=role' where role is one of: admin, write, read, list, contribute, member.\n\nExample:\n\n\x1b[1mtemporal cloud user-group create-google-group --display-name \"Platform\" \\\n --google-group-email platform@example.com \\\n --account-role developer \\\n --project-access my-project-id=write\x1b[0m" } else { - s.Command.Long = "Create a new user group backed by a Google Group. Members are managed\nvia the Google Group itself.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\n\nExample:\n\n```\ntemporal cloud user-group create-google-group --display-name \"Platform\" \\\n --google-group-email platform@example.com \\\n --account-role developer\n```" + s.Command.Long = "Create a new user group backed by a Google Group. Members are managed\nvia the Google Group itself.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\nProject access format: 'project-id=role' where role is one of: admin, write, read, list, contribute, member.\n\nExample:\n\n```\ntemporal cloud user-group create-google-group --display-name \"Platform\" \\\n --google-group-email platform@example.com \\\n --account-role developer \\\n --project-access my-project-id=write\n```" } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.DisplayName, "display-name", "", "The display name of the user group. Required.") @@ -6069,6 +6125,7 @@ func NewCloudUserGroupCreateGoogleGroupCommand(cctx *CommandContext, parent *Clo _ = cobra.MarkFlagRequired(s.Command.Flags(), "google-group-email") s.Command.Flags().StringVar(&s.AccountRole, "account-role", "", "The account-level role to assign. Valid values: owner, admin, developer, finance-admin, read, metrics-read.") s.Command.Flags().StringArrayVar(&s.NamespaceAccess, "namespace-access", nil, "Namespace access to grant, in the format 'namespace=permission'. Permission must be one of: admin, write, read. Can be repeated.") + s.Command.Flags().StringArrayVar(&s.ProjectAccess, "project-access", nil, "Project access to grant, in the format 'project-id=role'. Role must be one of: admin, write, read, list, contribute, member. Can be repeated.") s.Command.Flags().StringArrayVar(&s.CustomRole, "custom-role", nil, "Custom role ID to assign. Repeat to assign multiple.") s.ClientOptions.BuildFlags(s.Command.Flags()) s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) @@ -6089,6 +6146,7 @@ type CloudUserGroupCreateScimGroupCommand struct { ScimIdpId string AccountRole string NamespaceAccess []string + ProjectAccess []string CustomRole []string } @@ -6099,9 +6157,9 @@ func NewCloudUserGroupCreateScimGroupCommand(cctx *CommandContext, parent *Cloud s.Command.Use = "create-scim-group [flags]" s.Command.Short = "Create a SCIM-backed user group" if hasHighlighting { - s.Command.Long = "Create a new user group backed by a SCIM identity provider group.\nMembers are managed via the upstream identity provider.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\n\nExample:\n\n\x1b[1mtemporal cloud user-group create-scim-group --display-name \"Security\" \\\n --scim-idp-id idp-group-id-123 \\\n --account-role read\x1b[0m" + s.Command.Long = "Create a new user group backed by a SCIM identity provider group.\nMembers are managed via the upstream identity provider.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\nProject access format: 'project-id=role' where role is one of: admin, write, read, list, contribute, member.\n\nExample:\n\n\x1b[1mtemporal cloud user-group create-scim-group --display-name \"Security\" \\\n --scim-idp-id idp-group-id-123 \\\n --account-role read \\\n --project-access my-project-id=read\x1b[0m" } else { - s.Command.Long = "Create a new user group backed by a SCIM identity provider group.\nMembers are managed via the upstream identity provider.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\n\nExample:\n\n```\ntemporal cloud user-group create-scim-group --display-name \"Security\" \\\n --scim-idp-id idp-group-id-123 \\\n --account-role read\n```" + s.Command.Long = "Create a new user group backed by a SCIM identity provider group.\nMembers are managed via the upstream identity provider.\n\nAccount roles: owner, admin, developer, finance-admin, read, metrics-read.\nNamespace access format: 'namespace=permission' where permission is one of: admin, write, read.\nProject access format: 'project-id=role' where role is one of: admin, write, read, list, contribute, member.\n\nExample:\n\n```\ntemporal cloud user-group create-scim-group --display-name \"Security\" \\\n --scim-idp-id idp-group-id-123 \\\n --account-role read \\\n --project-access my-project-id=read\n```" } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.DisplayName, "display-name", "", "The display name of the user group. Required.") @@ -6110,6 +6168,7 @@ func NewCloudUserGroupCreateScimGroupCommand(cctx *CommandContext, parent *Cloud _ = cobra.MarkFlagRequired(s.Command.Flags(), "scim-idp-id") s.Command.Flags().StringVar(&s.AccountRole, "account-role", "", "The account-level role to assign. Valid values: owner, admin, developer, finance-admin, read, metrics-read.") s.Command.Flags().StringArrayVar(&s.NamespaceAccess, "namespace-access", nil, "Namespace access to grant, in the format 'namespace=permission'. Permission must be one of: admin, write, read. Can be repeated.") + s.Command.Flags().StringArrayVar(&s.ProjectAccess, "project-access", nil, "Project access to grant, in the format 'project-id=role'. Role must be one of: admin, write, read, list, contribute, member. Can be repeated.") s.Command.Flags().StringArrayVar(&s.CustomRole, "custom-role", nil, "Custom role ID to assign. Repeat to assign multiple.") s.ClientOptions.BuildFlags(s.Command.Flags()) s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) @@ -6225,6 +6284,7 @@ type CloudUserGroupListCommand struct { PageSize int PageToken string Namespace string + ProjectId string DisplayName string GoogleGroupEmailAddress string ScimGroupIdpId string @@ -6245,6 +6305,7 @@ func NewCloudUserGroupListCommand(cctx *CommandContext, parent *CloudUserGroupCo s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Number of user groups to return per page. Use for paginated results.") s.Command.Flags().StringVar(&s.PageToken, "page-token", "", "Token for retrieving the next page of results in a paginated list.") s.Command.Flags().StringVar(&s.Namespace, "namespace", "", "Filter user groups by the namespace they have access to.") + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "List user groups with access to the project ID. Cannot be combined with --namespace, --display-name, --google-group-email-address, or --scim-group-idp-id.") s.Command.Flags().StringVar(&s.DisplayName, "display-name", "", "Filter user groups by display name.") s.Command.Flags().StringVar(&s.GoogleGroupEmailAddress, "google-group-email-address", "", "Filter user groups by Google group email address.") s.Command.Flags().StringVar(&s.ScimGroupIdpId, "scim-group-idp-id", "", "Filter user groups by SCIM group IDP ID.") @@ -6374,6 +6435,42 @@ func NewCloudUserGroupMembersRemoveCommand(cctx *CommandContext, parent *CloudUs return &s } +type CloudUserGroupRemoveProjectAccessCommand struct { + Parent *CloudUserGroupCommand + Command cobra.Command + ClientOptions + GroupIdOptions + AsyncOperationOptions + ResourceVersionOptions + ProjectId string +} + +func NewCloudUserGroupRemoveProjectAccessCommand(cctx *CommandContext, parent *CloudUserGroupCommand) *CloudUserGroupRemoveProjectAccessCommand { + var s CloudUserGroupRemoveProjectAccessCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "remove-project-access [flags]" + s.Command.Short = "Remove project access for a user group" + if hasHighlighting { + s.Command.Long = "Remove a Temporal Cloud user group's direct project-level access.\n\nExample:\n\n\x1b[1mtemporal cloud user-group remove-project-access --group-id my-group-id \\\n --project-id my-project-id\x1b[0m" + } else { + s.Command.Long = "Remove a Temporal Cloud user group's direct project-level access.\n\nExample:\n\n```\ntemporal cloud user-group remove-project-access --group-id my-group-id \\\n --project-id my-project-id\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "project-id") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.GroupIdOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type CloudUserGroupSetAccountRoleCommand struct { Parent *CloudUserGroupCommand Command cobra.Command @@ -6481,6 +6578,45 @@ func NewCloudUserGroupSetNamespacePermissionsCommand(cctx *CommandContext, paren return &s } +type CloudUserGroupSetProjectAccessCommand struct { + Parent *CloudUserGroupCommand + Command cobra.Command + ClientOptions + GroupIdOptions + AsyncOperationOptions + ResourceVersionOptions + ProjectId string + ProjectRole string +} + +func NewCloudUserGroupSetProjectAccessCommand(cctx *CommandContext, parent *CloudUserGroupCommand) *CloudUserGroupSetProjectAccessCommand { + var s CloudUserGroupSetProjectAccessCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "set-project-access [flags]" + s.Command.Short = "Set project access for a user group" + if hasHighlighting { + s.Command.Long = "Set project-level access for a Temporal Cloud user group.\n\nProject roles: admin, write, read, list, contribute, member.\n\nExample:\n\n\x1b[1mtemporal cloud user-group set-project-access --group-id my-group-id \\\n --project-id my-project-id --project-role write\x1b[0m" + } else { + s.Command.Long = "Set project-level access for a Temporal Cloud user group.\n\nProject roles: admin, write, read, list, contribute, member.\n\nExample:\n\n```\ntemporal cloud user-group set-project-access --group-id my-group-id \\\n --project-id my-project-id --project-role write\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().StringVar(&s.ProjectId, "project-id", "", "The ID of the project. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "project-id") + s.Command.Flags().StringVar(&s.ProjectRole, "project-role", "", "The project-level role to assign. Valid values: admin, write, read, list, contribute, member. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "project-role") + s.ClientOptions.BuildFlags(s.Command.Flags()) + s.GroupIdOptions.BuildFlags(s.Command.Flags()) + s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) + s.ResourceVersionOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type CloudUserGroupUpdateCommand struct { Parent *CloudUserGroupCommand Command cobra.Command @@ -6491,6 +6627,7 @@ type CloudUserGroupUpdateCommand struct { CustomRoleOptions AccountRole string NamespaceAccess []string + ProjectAccess []string } func NewCloudUserGroupUpdateCommand(cctx *CommandContext, parent *CloudUserGroupCommand) *CloudUserGroupUpdateCommand { @@ -6500,13 +6637,14 @@ func NewCloudUserGroupUpdateCommand(cctx *CommandContext, parent *CloudUserGroup s.Command.Use = "update [flags]" s.Command.Short = "Update a Temporal Cloud user group" if hasHighlighting { - s.Command.Long = "Update an existing Temporal Cloud user group's access settings.\n\nProvide at least one of --account-role, --namespace-access, or --custom-role.\n\nExample:\n\n\x1b[1mtemporal cloud user-group update --group-id my-group-id --account-role developer\ntemporal cloud user-group update --group-id my-group-id \\\n --namespace-access my-namespace.my-account=write\ntemporal cloud user-group update --group-id my-group-id --account-role admin \\\n --namespace-access my-namespace.my-account=write \\\n --namespace-access other-namespace.my-account=read\x1b[0m" + s.Command.Long = "Update an existing Temporal Cloud user group's access settings.\n\nProvide at least one of --account-role, --namespace-access, --project-access, or --custom-role.\n\nExample:\n\n\x1b[1mtemporal cloud user-group update --group-id my-group-id --account-role developer\ntemporal cloud user-group update --group-id my-group-id \\\n --namespace-access my-namespace.my-account=write\ntemporal cloud user-group update --group-id my-group-id \\\n --project-access my-project-id=write\ntemporal cloud user-group update --group-id my-group-id --account-role admin \\\n --namespace-access my-namespace.my-account=write \\\n --namespace-access other-namespace.my-account=read \\\n --project-access my-project-id=read\x1b[0m" } else { - s.Command.Long = "Update an existing Temporal Cloud user group's access settings.\n\nProvide at least one of --account-role, --namespace-access, or --custom-role.\n\nExample:\n\n```\ntemporal cloud user-group update --group-id my-group-id --account-role developer\ntemporal cloud user-group update --group-id my-group-id \\\n --namespace-access my-namespace.my-account=write\ntemporal cloud user-group update --group-id my-group-id --account-role admin \\\n --namespace-access my-namespace.my-account=write \\\n --namespace-access other-namespace.my-account=read\n```" + s.Command.Long = "Update an existing Temporal Cloud user group's access settings.\n\nProvide at least one of --account-role, --namespace-access, --project-access, or --custom-role.\n\nExample:\n\n```\ntemporal cloud user-group update --group-id my-group-id --account-role developer\ntemporal cloud user-group update --group-id my-group-id \\\n --namespace-access my-namespace.my-account=write\ntemporal cloud user-group update --group-id my-group-id \\\n --project-access my-project-id=write\ntemporal cloud user-group update --group-id my-group-id --account-role admin \\\n --namespace-access my-namespace.my-account=write \\\n --namespace-access other-namespace.my-account=read \\\n --project-access my-project-id=read\n```" } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.AccountRole, "account-role", "", "The account role to assign to the group. Role must be one of: admin, developer, finance-admin, read.") s.Command.Flags().StringArrayVar(&s.NamespaceAccess, "namespace-access", nil, "Namespace access change in the format 'namespace=permission'. Permission must be one of: admin, write, read. Can be repeated. Use an empty permission (e.g. 'testns=') to remove access to a namespace. Changes are additive: namespaces not listed are left unchanged.") + s.Command.Flags().StringArrayVar(&s.ProjectAccess, "project-access", nil, "Project access change in the format 'project-id=role'. Role must be one of: admin, write, read, list, contribute, member. Can be repeated. Use an empty role (e.g. 'project-id=') to remove access to a project. Changes are additive: projects not listed are left unchanged.") s.ClientOptions.BuildFlags(s.Command.Flags()) s.GroupIdOptions.BuildFlags(s.Command.Flags()) s.AsyncOperationOptions.BuildFlags(s.Command.Flags()) diff --git a/temporalcloudcli/commands.project.go b/temporalcloudcli/commands.project.go index df0bd26..ded5a87 100644 --- a/temporalcloudcli/commands.project.go +++ b/temporalcloudcli/commands.project.go @@ -62,6 +62,10 @@ func (c *CloudProjectUserListCommand) run(cctx *CommandContext, _ []string) erro return printProjectUserAssignments(cctx, c.ClientOptions, c.ProjectId, c.PageSize, c.PageToken) } +func (c *CloudProjectUserGroupListCommand) run(cctx *CommandContext, _ []string) error { + return printProjectUserGroupAssignments(cctx, c.ClientOptions, c.ProjectId, c.PageSize, c.PageToken) +} + func (c *CloudProjectCreateCommand) run(cctx *CommandContext, _ []string) error { spec := projectSpecFromFlags(c.DisplayName, c.Description, c.EnableDeleteProtection) diff --git a/temporalcloudcli/commands.project_test.go b/temporalcloudcli/commands.project_test.go index 8ff92d4..fae7898 100644 --- a/temporalcloudcli/commands.project_test.go +++ b/temporalcloudcli/commands.project_test.go @@ -142,6 +142,71 @@ func TestProjectUserList_ApiError(t *testing.T) { }) } +func TestProjectUserGroupList(t *testing.T) { + type listOutput struct { + Groups []*identityv1.UserGroupProjectAssignment + NextPageToken string + } + + cmd := &temporalcloudcli.CloudProjectUserGroupListCommand{ + ProjectId: "project-a", + PageSize: 50, + PageToken: "next", + } + res := &cloudservice.GetUserGroupProjectAssignmentsResponse{ + Groups: []*identityv1.UserGroupProjectAssignment{ + { + Id: "group-1", + DisplayName: "Engineering", + ProjectAccess: &identityv1.ProjectAccess{ + Role: identityv1.ProjectAccess_PROJECT_ROLE_WRITE, + }, + }, + { + Id: "group-2", + DisplayName: "Platform", + InheritedAccess: true, + ProjectAccess: &identityv1.ProjectAccess{ + Role: identityv1.ProjectAccess_PROJECT_ROLE_DEVELOPER, + }, + }, + }, + NextPageToken: "next-2", + } + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroupProjectAssignments(mock.Anything, &cloudservice.GetUserGroupProjectAssignmentsRequest{ + ProjectId: "project-a", + PageSize: 50, + PageToken: "next", + }, mock.Anything). + Return(res, nil) + }, + JSONOutput: true, + ExpectedOutputJson: listOutput{ + Groups: res.Groups, + NextPageToken: "next-2", + }, + }) +} + +func TestProjectUserGroupList_ApiError(t *testing.T) { + cmd := &temporalcloudcli.CloudProjectUserGroupListCommand{ProjectId: "project-a"} + + temporalcloudcli.TestCommand(t, cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroupProjectAssignments(mock.Anything, &cloudservice.GetUserGroupProjectAssignmentsRequest{ + ProjectId: "project-a", + }, mock.Anything). + Return(nil, errors.New("api error")) + }, + ExpectedError: "api error", + }) +} + func TestProjectCreate(t *testing.T) { cmd := &temporalcloudcli.CloudProjectCreateCommand{ DisplayName: "Engineering", diff --git a/temporalcloudcli/commands.user.go b/temporalcloudcli/commands.user.go index 23adab4..02d610a 100644 --- a/temporalcloudcli/commands.user.go +++ b/temporalcloudcli/commands.user.go @@ -504,6 +504,34 @@ func parseProjectAccesses(accesses []string) (map[string]*identityv1.ProjectAcce return result, nil } +// applyProjectAccessChanges merges project access changes into an existing map. +// Each change is in "project-id=role" format. An empty role removes that project. +func applyProjectAccessChanges(existing map[string]*identityv1.ProjectAccess, changes []string) (map[string]*identityv1.ProjectAccess, error) { + result := make(map[string]*identityv1.ProjectAccess, len(existing)) + for k, v := range existing { + result[k] = v + } + for _, a := range changes { + projectID, role, ok := strings.Cut(a, "=") + if !ok { + return nil, fmt.Errorf("invalid project-access %q: must be in the format 'project-id=role'", a) + } + if role == "" { + delete(result, projectID) + continue + } + projectAccess, err := parseProjectRole(role) + if err != nil { + return nil, fmt.Errorf("%w in project-access %q", err, a) + } + result[projectID] = projectAccess + } + if len(result) == 0 { + return nil, nil + } + return result, nil +} + // applyCustomRoleChanges returns the new CustomRoles slice for an update // command, given the existing list and whether --custom-role was passed. // diff --git a/temporalcloudcli/commands.user_group.go b/temporalcloudcli/commands.user_group.go index 2771bc0..964e304 100644 --- a/temporalcloudcli/commands.user_group.go +++ b/temporalcloudcli/commands.user_group.go @@ -24,6 +24,13 @@ func (c *CloudUserGroupGetCommand) run(cctx *CommandContext, _ []string) error { } func (c *CloudUserGroupListCommand) run(cctx *CommandContext, _ []string) error { + if c.ProjectId != "" { + if c.Namespace != "" || c.DisplayName != "" || c.GoogleGroupEmailAddress != "" || c.ScimGroupIdpId != "" { + return errors.New("--project-id cannot be combined with --namespace, --display-name, --google-group-email-address, or --scim-group-idp-id") + } + return printProjectUserGroupAssignments(cctx, c.ClientOptions, c.ProjectId, c.PageSize, c.PageToken) + } + client, err := cctx.GetCloudClient(c.ClientOptions) if err != nil { return err @@ -94,6 +101,10 @@ func (c *CloudUserGroupCreateCloudGroupCommand) run(cctx *CommandContext, _ []st if err != nil { return err } + projectAccesses, err := parseProjectAccesses(c.ProjectAccess) + if err != nil { + return err + } if c.Command.Flags().Changed("custom-role") { if accountAccess == nil { return errors.New("--custom-role requires --account-role; a principal must have a account role") @@ -115,10 +126,11 @@ func (c *CloudUserGroupCreateCloudGroupCommand) run(cctx *CommandContext, _ []st DisplayName: c.DisplayName, GroupType: &identityv1.UserGroupSpec_CloudGroup{CloudGroup: &identityv1.CloudGroupSpec{}}, } - if accountAccess != nil || len(namespaceAccesses) > 0 { + if accountAccess != nil || len(namespaceAccesses) > 0 || len(projectAccesses) > 0 { spec.Access = &identityv1.Access{ AccountAccess: accountAccess, NamespaceAccesses: namespaceAccesses, + ProjectAccesses: projectAccesses, } } resp, err := client.CreateUserGroup(cctx, &cloudservice.CreateUserGroupRequest{ @@ -137,6 +149,10 @@ func (c *CloudUserGroupCreateGoogleGroupCommand) run(cctx *CommandContext, _ []s if err != nil { return err } + projectAccesses, err := parseProjectAccesses(c.ProjectAccess) + if err != nil { + return err + } if c.Command.Flags().Changed("custom-role") { if accountAccess == nil { return errors.New("--custom-role requires --account-role; a principal must have a account role") @@ -160,10 +176,11 @@ func (c *CloudUserGroupCreateGoogleGroupCommand) run(cctx *CommandContext, _ []s GoogleGroup: &identityv1.GoogleGroupSpec{EmailAddress: c.GoogleGroupEmail}, }, } - if accountAccess != nil || len(namespaceAccesses) > 0 { + if accountAccess != nil || len(namespaceAccesses) > 0 || len(projectAccesses) > 0 { spec.Access = &identityv1.Access{ AccountAccess: accountAccess, NamespaceAccesses: namespaceAccesses, + ProjectAccesses: projectAccesses, } } resp, err := client.CreateUserGroup(cctx, &cloudservice.CreateUserGroupRequest{ @@ -182,6 +199,10 @@ func (c *CloudUserGroupCreateScimGroupCommand) run(cctx *CommandContext, _ []str if err != nil { return err } + projectAccesses, err := parseProjectAccesses(c.ProjectAccess) + if err != nil { + return err + } if c.Command.Flags().Changed("custom-role") { if accountAccess == nil { return errors.New("--custom-role requires --account-role; a principal must have a account role") @@ -205,10 +226,11 @@ func (c *CloudUserGroupCreateScimGroupCommand) run(cctx *CommandContext, _ []str ScimGroup: &identityv1.SCIMGroupSpec{IdpId: c.ScimIdpId}, }, } - if accountAccess != nil || len(namespaceAccesses) > 0 { + if accountAccess != nil || len(namespaceAccesses) > 0 || len(projectAccesses) > 0 { spec.Access = &identityv1.Access{ AccountAccess: accountAccess, NamespaceAccesses: namespaceAccesses, + ProjectAccesses: projectAccesses, } } resp, err := client.CreateUserGroup(cctx, &cloudservice.CreateUserGroupRequest{ @@ -307,13 +329,16 @@ func (c *CloudUserGroupEditCommand) run(cctx *CommandContext, _ []string) error func (c *CloudUserGroupUpdateCommand) run(cctx *CommandContext, _ []string) error { customRoleProvided := c.Command.Flags().Changed("custom-role") - if c.AccountRole == "" && len(c.NamespaceAccess) == 0 && !customRoleProvided { - return errors.New("must provide at least one of --account-role, --namespace-access, or --custom-role") + if c.AccountRole == "" && len(c.NamespaceAccess) == 0 && len(c.ProjectAccess) == 0 && !customRoleProvided { + return errors.New("must provide at least one of --account-role, --namespace-access, --project-access, or --custom-role") } // Validate inputs before any API call. if _, err := applyNamespaceAccessChanges(nil, c.NamespaceAccess); err != nil { return err } + if _, err := applyProjectAccessChanges(nil, c.ProjectAccess); err != nil { + return err + } var accountAccess *identityv1.AccountAccess if c.AccountRole != "" { var err error @@ -349,6 +374,13 @@ func (c *CloudUserGroupUpdateCommand) run(cctx *CommandContext, _ []string) erro } newSpec.Access.NamespaceAccesses = namespaceAccesses } + if len(c.ProjectAccess) > 0 { + projectAccesses, err := applyProjectAccessChanges(newSpec.Access.ProjectAccesses, c.ProjectAccess) + if err != nil { + return err + } + newSpec.Access.ProjectAccesses = projectAccesses + } if customRoleProvided { if newSpec.Access.AccountAccess == nil { return errors.New("group has no account access; assign an account role with --account-role first") @@ -419,6 +451,69 @@ func (c *CloudUserGroupSetAccountRoleCommand) run(cctx *CommandContext, _ []stri return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) } +func (c *CloudUserGroupSetProjectAccessCommand) run(cctx *CommandContext, _ []string) error { + projectAccess, err := parseProjectRole(c.ProjectRole) + if err != nil { + return err + } + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + res, err := client.GetUserGroup(cctx, &cloudservice.GetUserGroupRequest{GroupId: c.GroupId}) + if err != nil { + return err + } + yes, err := cctx.GetPrompter().PromptYes("Set project access") + if err != nil { + return err + } + if !yes { + return errors.New("Aborting set.") + } + rv := res.Group.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.SetUserGroupProjectAccess(cctx, &cloudservice.SetUserGroupProjectAccessRequest{ + ProjectId: c.ProjectId, + GroupId: c.GroupId, + Access: projectAccess, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + +func (c *CloudUserGroupRemoveProjectAccessCommand) run(cctx *CommandContext, _ []string) error { + client, err := cctx.GetCloudClient(c.ClientOptions) + if err != nil { + return err + } + res, err := client.GetUserGroup(cctx, &cloudservice.GetUserGroupRequest{GroupId: c.GroupId}) + if err != nil { + return err + } + yes, err := cctx.GetPrompter().PromptYes("Remove project access") + if err != nil { + return err + } + if !yes { + return errors.New("Aborting remove.") + } + rv := res.Group.ResourceVersion + if c.ResourceVersion != "" { + rv = c.ResourceVersion + } + resp, err := client.SetUserGroupProjectAccess(cctx, &cloudservice.SetUserGroupProjectAccessRequest{ + ProjectId: c.ProjectId, + GroupId: c.GroupId, + ResourceVersion: rv, + AsyncOperationId: c.AsyncOperationId, + }) + return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) +} + func (c *CloudUserGroupSetNamespacePermissionsCommand) run(cctx *CommandContext, _ []string) error { // Validate inputs before any API call. if _, err := applyNamespaceAccessChanges(nil, c.NamespaceAccess); err != nil { @@ -461,6 +556,40 @@ func (c *CloudUserGroupSetNamespacePermissionsCommand) run(cctx *CommandContext, return cctx.GetPoller(client, c.AsyncOperationOptions).HandleUpdateOperation(cctx, resp, err) } +func printProjectUserGroupAssignments( + cctx *CommandContext, + clientOptions ClientOptions, + projectID string, + pageSize int, + pageToken string, +) error { + client, err := cctx.GetCloudClient(clientOptions) + if err != nil { + return err + } + res, err := client.GetUserGroupProjectAssignments(cctx, &cloudservice.GetUserGroupProjectAssignmentsRequest{ + ProjectId: projectID, + PageSize: int32(pageSize), + PageToken: pageToken, + }) + if err != nil { + return err + } + return cctx.Printer.PrintResourceList( + struct { + Groups []*identityv1.UserGroupProjectAssignment + NextPageToken string + }{ + Groups: res.Groups, + NextPageToken: res.NextPageToken, + }, + printer.PrintResourceOptions{ + Fields: []string{"Id", "DisplayName", "ProjectAccess", "InheritedAccess"}, + }, + printer.TableOptions{}, + ) +} + func (c *CloudUserGroupSetCustomRolesCommand) run(cctx *CommandContext, _ []string) error { client, err := cctx.GetCloudClient(c.ClientOptions) if err != nil { diff --git a/temporalcloudcli/commands.user_group_test.go b/temporalcloudcli/commands.user_group_test.go index b5c8a59..f67ce67 100644 --- a/temporalcloudcli/commands.user_group_test.go +++ b/temporalcloudcli/commands.user_group_test.go @@ -105,6 +105,34 @@ func TestListUserGroups(t *testing.T) { Return(&cloudservice.GetUserGroupsResponse{}, nil) }, }, + { + name: "ProjectId", + cmd: temporalcloudcli.CloudUserGroupListCommand{ + ProjectId: "project-1", + PageSize: 50, + PageToken: "next", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroupProjectAssignments(mock.Anything, &cloudservice.GetUserGroupProjectAssignmentsRequest{ + ProjectId: "project-1", + PageSize: 50, + PageToken: "next", + }, mock.Anything). + Return(&cloudservice.GetUserGroupProjectAssignmentsResponse{ + Groups: []*identityv1.UserGroupProjectAssignment{{Id: "group-1", DisplayName: "Engineering"}}, + NextPageToken: "next-2", + }, nil) + }, + }, + { + name: "ProjectIdWithOtherFilter", + cmd: temporalcloudcli.CloudUserGroupListCommand{ + ProjectId: "project-1", + DisplayName: "Engineering", + }, + expectedErr: "--project-id cannot be combined", + }, { name: "GetError", cmd: temporalcloudcli.CloudUserGroupListCommand{}, @@ -240,6 +268,32 @@ func TestCreateCloudGroup(t *testing.T) { promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-1"}, }, + { + name: "ProjectAccess", + cmd: temporalcloudcli.CloudUserGroupCreateCloudGroupCommand{ + DisplayName: "Engineering", + ProjectAccess: []string{"project-1=write"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + CreateUserGroup(mock.Anything, &cloudservice.CreateUserGroupRequest{ + Spec: &identityv1.UserGroupSpec{ + DisplayName: "Engineering", + GroupType: &identityv1.UserGroupSpec_CloudGroup{CloudGroup: &identityv1.CloudGroupSpec{}}, + Access: &identityv1.Access{ + ProjectAccesses: map[string]*identityv1.ProjectAccess{ + "project-1": {Role: identityv1.ProjectAccess_PROJECT_ROLE_WRITE}, + }, + }, + }, + }, mock.Anything). + Return(&cloudservice.CreateUserGroupResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-1"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-1"}, + }, { name: "PromptDeclined", cmd: temporalcloudcli.CloudUserGroupCreateCloudGroupCommand{DisplayName: "Engineering"}, @@ -309,6 +363,35 @@ func TestCreateGoogleGroup(t *testing.T) { promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-2"}, }, + { + name: "ProjectAccess", + cmd: temporalcloudcli.CloudUserGroupCreateGoogleGroupCommand{ + DisplayName: "Platform", + GoogleGroupEmail: "platform@example.com", + ProjectAccess: []string{"project-1=member"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + CreateUserGroup(mock.Anything, &cloudservice.CreateUserGroupRequest{ + Spec: &identityv1.UserGroupSpec{ + DisplayName: "Platform", + GroupType: &identityv1.UserGroupSpec_GoogleGroup{ + GoogleGroup: &identityv1.GoogleGroupSpec{EmailAddress: "platform@example.com"}, + }, + Access: &identityv1.Access{ + ProjectAccesses: map[string]*identityv1.ProjectAccess{ + "project-1": {Role: identityv1.ProjectAccess_PROJECT_ROLE_MEMBER}, + }, + }, + }, + }, mock.Anything). + Return(&cloudservice.CreateUserGroupResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-2"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-2"}, + }, { name: "PromptDeclined", cmd: temporalcloudcli.CloudUserGroupCreateGoogleGroupCommand{ @@ -376,6 +459,35 @@ func TestCreateScimGroup(t *testing.T) { promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-3"}, }, + { + name: "ProjectAccess", + cmd: temporalcloudcli.CloudUserGroupCreateScimGroupCommand{ + DisplayName: "Security", + ScimIdpId: "idp-abc", + ProjectAccess: []string{"project-1=read"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + CreateUserGroup(mock.Anything, &cloudservice.CreateUserGroupRequest{ + Spec: &identityv1.UserGroupSpec{ + DisplayName: "Security", + GroupType: &identityv1.UserGroupSpec_ScimGroup{ + ScimGroup: &identityv1.SCIMGroupSpec{IdpId: "idp-abc"}, + }, + Access: &identityv1.Access{ + ProjectAccesses: map[string]*identityv1.ProjectAccess{ + "project-1": {Role: identityv1.ProjectAccess_PROJECT_ROLE_READ}, + }, + }, + }, + }, mock.Anything). + Return(&cloudservice.CreateUserGroupResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-3"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-3"}, + }, { name: "PromptDeclined", cmd: temporalcloudcli.CloudUserGroupCreateScimGroupCommand{DisplayName: "Security", ScimIdpId: "idp-abc"}, @@ -648,16 +760,73 @@ func TestUpdateUserGroup(t *testing.T) { promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-upd"}, }, + { + name: "ProjectAccess", + cmd: temporalcloudcli.CloudUserGroupUpdateCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{ + GroupId: "group-1", + }, + ProjectAccess: []string{"old-project=", "new-project=write"}, + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroup(mock.Anything, &cloudservice.GetUserGroupRequest{GroupId: "group-1"}, mock.Anything). + Return(&cloudservice.GetUserGroupResponse{ + Group: &identityv1.UserGroup{ + Id: "group-1", + ResourceVersion: "rv-1", + Spec: &identityv1.UserGroupSpec{ + DisplayName: "Engineering", + GroupType: &identityv1.UserGroupSpec_CloudGroup{CloudGroup: &identityv1.CloudGroupSpec{}}, + Access: &identityv1.Access{ + ProjectAccesses: map[string]*identityv1.ProjectAccess{ + "old-project": {Role: identityv1.ProjectAccess_PROJECT_ROLE_READ}, + }, + }, + }, + }, + }, nil) + c.EXPECT(). + UpdateUserGroup(mock.Anything, &cloudservice.UpdateUserGroupRequest{ + GroupId: "group-1", + Spec: &identityv1.UserGroupSpec{ + DisplayName: "Engineering", + GroupType: &identityv1.UserGroupSpec_CloudGroup{CloudGroup: &identityv1.CloudGroupSpec{}}, + Access: &identityv1.Access{ + ProjectAccesses: map[string]*identityv1.ProjectAccess{ + "new-project": {Role: identityv1.ProjectAccess_PROJECT_ROLE_WRITE}, + }, + }, + }, + ResourceVersion: "rv-1", + }, mock.Anything). + Return(&cloudservice.UpdateUserGroupResponse{ + AsyncOperation: &operation.AsyncOperation{Id: "op-upd"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPrompApply: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-upd"}, + }, { name: "NoFlags", cmd: temporalcloudcli.CloudUserGroupUpdateCommand{GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}}, - expectedErr: "must provide at least one of --account-role, --namespace-access, or --custom-role", + expectedErr: "must provide at least one of --account-role, --namespace-access, --project-access, or --custom-role", }, { name: "InvalidRole", cmd: temporalcloudcli.CloudUserGroupUpdateCommand{GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, AccountRole: "superadmin"}, expectedErr: "invalid account role", }, + { + name: "InvalidProjectAccessFormat", + cmd: temporalcloudcli.CloudUserGroupUpdateCommand{GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, ProjectAccess: []string{"project-1"}}, + expectedErr: "invalid project-access", + }, + { + name: "InvalidProjectAccessRole", + cmd: temporalcloudcli.CloudUserGroupUpdateCommand{GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, ProjectAccess: []string{"project-1=developer"}}, + expectedErr: "invalid project role", + }, { name: "GetUserGroupError", cmd: temporalcloudcli.CloudUserGroupUpdateCommand{GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, AccountRole: "developer"}, @@ -779,6 +948,225 @@ func TestSetUserGroupAccountRole(t *testing.T) { } } +// ---- SetUserGroupProjectAccess ---- + +func TestSetUserGroupProjectAccess(t *testing.T) { + op := &operation.AsyncOperation{Id: "op-project"} + tests := []struct { + name string + cmd temporalcloudcli.CloudUserGroupSetProjectAccessCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + promptOptions temporalcloudcli.TestPromptOptions + asyncPollerOptions temporalcloudcli.TestAsyncPollerOptions + expectedErr string + }{ + { + name: "Success", + cmd: temporalcloudcli.CloudUserGroupSetProjectAccessCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, + ProjectId: "project-1", + ProjectRole: "write", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroup(mock.Anything, &cloudservice.GetUserGroupRequest{GroupId: "group-1"}, mock.Anything). + Return(&cloudservice.GetUserGroupResponse{ + Group: &identityv1.UserGroup{Id: "group-1", ResourceVersion: "rv-1"}, + }, nil) + c.EXPECT(). + SetUserGroupProjectAccess(mock.Anything, &cloudservice.SetUserGroupProjectAccessRequest{ + ProjectId: "project-1", + GroupId: "group-1", + Access: &identityv1.ProjectAccess{Role: identityv1.ProjectAccess_PROJECT_ROLE_WRITE}, + ResourceVersion: "rv-1", + }, mock.Anything). + Return(&cloudservice.SetUserGroupProjectAccessResponse{AsyncOperation: op}, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-project"}, + }, + { + name: "ResourceVersionOverride", + cmd: temporalcloudcli.CloudUserGroupSetProjectAccessCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, + ResourceVersionOptions: temporalcloudcli.ResourceVersionOptions{ResourceVersion: "rv-override"}, + ProjectId: "project-1", + ProjectRole: "admin", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroup(mock.Anything, &cloudservice.GetUserGroupRequest{GroupId: "group-1"}, mock.Anything). + Return(&cloudservice.GetUserGroupResponse{ + Group: &identityv1.UserGroup{Id: "group-1", ResourceVersion: "rv-1"}, + }, nil) + c.EXPECT(). + SetUserGroupProjectAccess(mock.Anything, &cloudservice.SetUserGroupProjectAccessRequest{ + ProjectId: "project-1", + GroupId: "group-1", + Access: &identityv1.ProjectAccess{Role: identityv1.ProjectAccess_PROJECT_ROLE_ADMIN}, + ResourceVersion: "rv-override", + }, mock.Anything). + Return(&cloudservice.SetUserGroupProjectAccessResponse{AsyncOperation: op}, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-project"}, + }, + { + name: "InvalidRole", + cmd: temporalcloudcli.CloudUserGroupSetProjectAccessCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, + ProjectId: "project-1", + ProjectRole: "developer", + }, + expectedErr: "invalid project role", + }, + { + name: "PromptDeclined", + cmd: temporalcloudcli.CloudUserGroupSetProjectAccessCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, + ProjectId: "project-1", + ProjectRole: "write", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroup(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetUserGroupResponse{ + Group: &identityv1.UserGroup{Id: "group-1", ResourceVersion: "rv-1"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: false}, + expectedErr: "Aborting set.", + }, + { + name: "GetUserGroupError", + cmd: temporalcloudcli.CloudUserGroupSetProjectAccessCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, + ProjectId: "project-1", + ProjectRole: "write", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroup(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("not found")) + }, + expectedErr: "not found", + }, + { + name: "ApiError", + cmd: temporalcloudcli.CloudUserGroupSetProjectAccessCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, + ProjectId: "project-1", + ProjectRole: "write", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroup(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetUserGroupResponse{ + Group: &identityv1.UserGroup{Id: "group-1", ResourceVersion: "rv-1"}, + }, nil) + c.EXPECT(). + SetUserGroupProjectAccess(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("set error")) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + expectedErr: "set error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + PromptOptions: tt.promptOptions, + AsyncPollerOptions: tt.asyncPollerOptions, + ExpectedError: tt.expectedErr, + }) + }) + } +} + +// ---- RemoveUserGroupProjectAccess ---- + +func TestRemoveUserGroupProjectAccess(t *testing.T) { + op := &operation.AsyncOperation{Id: "op-project-remove"} + tests := []struct { + name string + cmd temporalcloudcli.CloudUserGroupRemoveProjectAccessCommand + cloudClientExpectations func(*cloudmock.MockCloudServiceClient) + promptOptions temporalcloudcli.TestPromptOptions + asyncPollerOptions temporalcloudcli.TestAsyncPollerOptions + expectedErr string + }{ + { + name: "Success", + cmd: temporalcloudcli.CloudUserGroupRemoveProjectAccessCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, + ProjectId: "project-1", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroup(mock.Anything, &cloudservice.GetUserGroupRequest{GroupId: "group-1"}, mock.Anything). + Return(&cloudservice.GetUserGroupResponse{ + Group: &identityv1.UserGroup{Id: "group-1", ResourceVersion: "rv-1"}, + }, nil) + c.EXPECT(). + SetUserGroupProjectAccess(mock.Anything, &cloudservice.SetUserGroupProjectAccessRequest{ + ProjectId: "project-1", + GroupId: "group-1", + ResourceVersion: "rv-1", + }, mock.Anything). + Return(&cloudservice.SetUserGroupProjectAccessResponse{AsyncOperation: op}, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + asyncPollerOptions: temporalcloudcli.TestAsyncPollerOptions{AsyncOperationID: "op-project-remove"}, + }, + { + name: "PromptDeclined", + cmd: temporalcloudcli.CloudUserGroupRemoveProjectAccessCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, + ProjectId: "project-1", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroup(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetUserGroupResponse{ + Group: &identityv1.UserGroup{Id: "group-1", ResourceVersion: "rv-1"}, + }, nil) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: false}, + expectedErr: "Aborting remove.", + }, + { + name: "ApiError", + cmd: temporalcloudcli.CloudUserGroupRemoveProjectAccessCommand{ + GroupIdOptions: temporalcloudcli.GroupIdOptions{GroupId: "group-1"}, + ProjectId: "project-1", + }, + cloudClientExpectations: func(c *cloudmock.MockCloudServiceClient) { + c.EXPECT(). + GetUserGroup(mock.Anything, mock.Anything, mock.Anything). + Return(&cloudservice.GetUserGroupResponse{ + Group: &identityv1.UserGroup{Id: "group-1", ResourceVersion: "rv-1"}, + }, nil) + c.EXPECT(). + SetUserGroupProjectAccess(mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("remove error")) + }, + promptOptions: temporalcloudcli.TestPromptOptions{ExpectPromptYes: true, PromptResult: true}, + expectedErr: "remove error", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temporalcloudcli.TestCommand(t, &tt.cmd, temporalcloudcli.TestCommandOptions{ + CloudClientExpectations: tt.cloudClientExpectations, + PromptOptions: tt.promptOptions, + AsyncPollerOptions: tt.asyncPollerOptions, + ExpectedError: tt.expectedErr, + }) + }) + } +} + // ---- SetUserGroupNamespacePermissions ---- func TestSetUserGroupNamespacePermissions(t *testing.T) { diff --git a/temporalcloudcli/commands.yml b/temporalcloudcli/commands.yml index 34794b4..2a1d3e8 100644 --- a/temporalcloudcli/commands.yml +++ b/temporalcloudcli/commands.yml @@ -2337,6 +2337,40 @@ commands: type: string description: | Token for retrieving the next page of results in a paginated list. + - name: cloud project user-group + summary: Inspect user groups with access to a project + description: | + Commands for inspecting the user groups that have access to a Temporal + Cloud project. + has-init: false + - name: cloud project user-group list + summary: List user groups with access to a project + description: | + List the user groups that have access to a Temporal Cloud project, + including both directly-assigned and inherited access. + + Example: + + ``` + temporal cloud project user-group list --project-id my-project-id + ``` + has-init: false + option-sets: + - client + options: + - name: project-id + type: string + required: true + description: | + The ID of the project. + - name: page-size + type: int + description: | + Number of user groups to return per page. Use for paginated results. + - name: page-token + type: string + description: | + Token for retrieving the next page of results in a paginated list. # User group commands - name: cloud user-group @@ -2393,13 +2427,15 @@ commands: Account roles: owner, admin, developer, finance-admin, read, metrics-read. Namespace access format: 'namespace=permission' where permission is one of: admin, write, read. + Project access format: 'project-id=role' where role is one of: admin, write, read, list, contribute, member. Example: ``` temporal cloud user-group create-cloud-group --display-name "Engineering" \ --account-role developer \ - --namespace-access my-namespace.my-account=write + --namespace-access my-namespace.my-account=write \ + --project-access my-project-id=write ``` has-init: false option-sets: @@ -2421,6 +2457,11 @@ commands: description: | Namespace access to grant, in the format 'namespace=permission'. Permission must be one of: admin, write, read. Can be repeated. + - name: project-access + type: string[] + description: | + Project access to grant, in the format 'project-id=role'. + Role must be one of: admin, write, read, list, contribute, member. Can be repeated. - name: custom-role type: string[] description: | @@ -2433,13 +2474,15 @@ commands: Account roles: owner, admin, developer, finance-admin, read, metrics-read. Namespace access format: 'namespace=permission' where permission is one of: admin, write, read. + Project access format: 'project-id=role' where role is one of: admin, write, read, list, contribute, member. Example: ``` temporal cloud user-group create-google-group --display-name "Platform" \ --google-group-email platform@example.com \ - --account-role developer + --account-role developer \ + --project-access my-project-id=write ``` has-init: false option-sets: @@ -2466,6 +2509,11 @@ commands: description: | Namespace access to grant, in the format 'namespace=permission'. Permission must be one of: admin, write, read. Can be repeated. + - name: project-access + type: string[] + description: | + Project access to grant, in the format 'project-id=role'. + Role must be one of: admin, write, read, list, contribute, member. Can be repeated. - name: custom-role type: string[] description: | @@ -2478,13 +2526,15 @@ commands: Account roles: owner, admin, developer, finance-admin, read, metrics-read. Namespace access format: 'namespace=permission' where permission is one of: admin, write, read. + Project access format: 'project-id=role' where role is one of: admin, write, read, list, contribute, member. Example: ``` temporal cloud user-group create-scim-group --display-name "Security" \ --scim-idp-id idp-group-id-123 \ - --account-role read + --account-role read \ + --project-access my-project-id=read ``` has-init: false option-sets: @@ -2511,6 +2561,11 @@ commands: description: | Namespace access to grant, in the format 'namespace=permission'. Permission must be one of: admin, write, read. Can be repeated. + - name: project-access + type: string[] + description: | + Project access to grant, in the format 'project-id=role'. + Role must be one of: admin, write, read, list, contribute, member. Can be repeated. - name: custom-role type: string[] description: | @@ -2556,6 +2611,12 @@ commands: type: string description: | Filter user groups by the namespace they have access to. + - name: project-id + type: string + description: | + List user groups with access to the project ID. Cannot be combined + with --namespace, --display-name, --google-group-email-address, or + --scim-group-idp-id. - name: display-name type: string description: | @@ -2590,7 +2651,7 @@ commands: description: | Update an existing Temporal Cloud user group's access settings. - Provide at least one of --account-role, --namespace-access, or --custom-role. + Provide at least one of --account-role, --namespace-access, --project-access, or --custom-role. Example: @@ -2598,9 +2659,12 @@ commands: temporal cloud user-group update --group-id my-group-id --account-role developer temporal cloud user-group update --group-id my-group-id \ --namespace-access my-namespace.my-account=write + temporal cloud user-group update --group-id my-group-id \ + --project-access my-project-id=write temporal cloud user-group update --group-id my-group-id --account-role admin \ --namespace-access my-namespace.my-account=write \ - --namespace-access other-namespace.my-account=read + --namespace-access other-namespace.my-account=read \ + --project-access my-project-id=read ``` has-init: false option-sets: @@ -2622,6 +2686,13 @@ commands: Permission must be one of: admin, write, read. Can be repeated. Use an empty permission (e.g. 'testns=') to remove access to a namespace. Changes are additive: namespaces not listed are left unchanged. + - name: project-access + type: string[] + description: | + Project access change in the format 'project-id=role'. + Role must be one of: admin, write, read, list, contribute, member. Can be repeated. + Use an empty role (e.g. 'project-id=') to remove access to a project. + Changes are additive: projects not listed are left unchanged. - name: cloud user-group edit summary: Interactively edit a user group configuration description: | @@ -2689,6 +2760,60 @@ commands: - async-operation - resource-version - custom-role + - name: cloud user-group set-project-access + summary: Set project access for a user group + description: | + Set project-level access for a Temporal Cloud user group. + + Project roles: admin, write, read, list, contribute, member. + + Example: + + ``` + temporal cloud user-group set-project-access --group-id my-group-id \ + --project-id my-project-id --project-role write + ``` + has-init: false + option-sets: + - client + - group-id + - async-operation + - resource-version + options: + - name: project-id + type: string + required: true + description: | + The ID of the project. + - name: project-role + type: string + required: true + description: | + The project-level role to assign. Valid values: admin, write, read, + list, contribute, member. + - name: cloud user-group remove-project-access + summary: Remove project access for a user group + description: | + Remove a Temporal Cloud user group's direct project-level access. + + Example: + + ``` + temporal cloud user-group remove-project-access --group-id my-group-id \ + --project-id my-project-id + ``` + has-init: false + option-sets: + - client + - group-id + - async-operation + - resource-version + options: + - name: project-id + type: string + required: true + description: | + The ID of the project. - name: cloud user-group set-namespace-permissions summary: Set namespace permissions for a user group description: |