From e82958b4e079a075283dc6526db1d206ffa51742 Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Thu, 18 Nov 2021 06:55:24 -0500 Subject: [PATCH] Support setting user Codespaces secrets (#4699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mislav Marohnić --- internal/ghrepo/repo.go | 8 ++- pkg/cmd/secret/secret.go | 8 +-- pkg/cmd/secret/set/http.go | 73 +++++++++++++++++++--- pkg/cmd/secret/set/set.go | 107 +++++++++++++++------------------ pkg/cmd/secret/set/set_test.go | 100 ++++++++++++++++++++++++------ 5 files changed, 208 insertions(+), 88 deletions(-) diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go index f16de0842..05c2f18c4 100644 --- a/internal/ghrepo/repo.go +++ b/internal/ghrepo/repo.go @@ -53,6 +53,12 @@ func SetDefaultHost(host string) { // FromFullName extracts the GitHub repository information from the following // formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL. func FromFullName(nwo string) (Interface, error) { + return FromFullNameWithHost(nwo, defaultHost()) +} + +// FromFullNameWithHost is like FromFullName that defaults to a specific host for values that don't +// explicitly include a hostname. +func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) { if git.IsURL(nwo) { u, err := git.ParseURL(nwo) if err != nil { @@ -71,7 +77,7 @@ func FromFullName(nwo string) (Interface, error) { case 3: return NewWithHost(parts[1], parts[2], parts[0]), nil case 2: - return NewWithHost(parts[0], parts[1], defaultHost()), nil + return NewWithHost(parts[0], parts[1], fallbackHost), nil default: return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo) } diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go index 0199c82bc..2ef5eeec6 100644 --- a/pkg/cmd/secret/secret.go +++ b/pkg/cmd/secret/secret.go @@ -15,11 +15,9 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { Short: "Manage GitHub secrets", Long: heredoc.Doc(` Secrets can be set at the repository, environment, or organization level for use in - GitHub Actions. Run "gh help secret set" to learn how to get started. - `), - Annotations: map[string]string{ - "IsActions": "true", - }, + GitHub Actions. User secrets can be set for use in GitHub Codespaces. + Run "gh help secret set" to learn how to get started. +`), } cmdutil.EnableRepoOverride(cmd, f) diff --git a/pkg/cmd/secret/set/http.go b/pkg/cmd/secret/set/http.go index 671b8c9fe..03fdb0d89 100644 --- a/pkg/cmd/secret/set/http.go +++ b/pkg/cmd/secret/set/http.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "sort" + "strconv" "strings" "github.com/cli/cli/v2/api" @@ -20,6 +21,13 @@ type SecretPayload struct { KeyID string `json:"key_id"` } +// The Codespaces Secret API currently expects repositories IDs as strings +type CodespacesSecretPayload struct { + EncryptedValue string `json:"encrypted_value"` + Repositories []string `json:"selected_repository_ids,omitempty"` + KeyID string `json:"key_id"` +} + type PubKey struct { Raw [32]byte ID string `json:"key_id"` @@ -50,6 +58,10 @@ func getOrgPublicKey(client *api.Client, host, orgName string) (*PubKey, error) return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)) } +func getUserPublicKey(client *api.Client, host string) (*PubKey, error) { + return getPubKey(client, host, "user/codespaces/secrets/public-key") +} + func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) { return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets/public-key", ghrepo.FullName(repo))) @@ -60,7 +72,7 @@ func getEnvPubKey(client *api.Client, repo ghrepo.Interface, envName string) (*P ghrepo.FullName(repo), envName)) } -func putSecret(client *api.Client, host, path string, payload SecretPayload) error { +func putSecret(client *api.Client, host, path string, payload interface{}) error { payloadBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to serialize: %w", err) @@ -78,7 +90,20 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, opts SetOptions, var repositoryIDs []int var err error if orgName != "" && visibility == shared.Selected { - repositoryIDs, err = mapRepoNameToID(client, host, orgName, opts.RepositoryNames) + repos := make([]ghrepo.Interface, 0, len(opts.RepositoryNames)) + for _, repositoryName := range opts.RepositoryNames { + var repo ghrepo.Interface + if strings.Contains(repositoryName, "/") { + repo, err = ghrepo.FromFullNameWithHost(repositoryName, host) + if err != nil { + return fmt.Errorf("invalid repository name: %w", err) + } + } else { + repo = ghrepo.NewWithHost(opts.OrgName, repositoryName, host) + } + repos = append(repos, repo) + } + repositoryIDs, err = mapRepoToID(client, host, repos) if err != nil { return fmt.Errorf("failed to look up IDs for repositories %v: %w", opts.RepositoryNames, err) } @@ -95,6 +120,38 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, opts SetOptions, return putSecret(client, host, path, payload) } +func putUserSecret(client *api.Client, host string, pk *PubKey, opts SetOptions, eValue string) error { + payload := CodespacesSecretPayload{ + EncryptedValue: eValue, + KeyID: pk.ID, + } + + if len(opts.RepositoryNames) > 0 { + repos := make([]ghrepo.Interface, len(opts.RepositoryNames)) + for i, repo := range opts.RepositoryNames { + // For user secrets, repository names should be fully qualifed (e.g. "owner/repo") + repoNWO, err := ghrepo.FromFullNameWithHost(repo, host) + if err != nil { + return err + } + repos[i] = repoNWO + } + + repositoryIDs, err := mapRepoToID(client, host, repos) + if err != nil { + return fmt.Errorf("failed to look up repository IDs: %w", err) + } + repositoryStringIDs := make([]string, len(repositoryIDs)) + for i, id := range repositoryIDs { + repositoryStringIDs[i] = strconv.Itoa(id) + } + payload.Repositories = repositoryStringIDs + } + + path := fmt.Sprintf("user/codespaces/secrets/%s", opts.SecretName) + return putSecret(client, host, path, payload) +} + func putEnvSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, envName string, secretName, eValue string) error { payload := SecretPayload{ EncryptedValue: eValue, @@ -114,14 +171,14 @@ func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secret } // This does similar logic to `api.RepoNetwork`, but without the overfetching. -func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames []string) ([]int, error) { - queries := make([]string, 0, len(repositoryNames)) - for i, repoName := range repositoryNames { +func mapRepoToID(client *api.Client, host string, repositories []ghrepo.Interface) ([]int, error) { + queries := make([]string, 0, len(repositories)) + for i, repo := range repositories { queries = append(queries, fmt.Sprintf(` repo_%03d: repository(owner: %q, name: %q) { databaseId } - `, i, orgName, repoName)) + `, i, repo.RepoOwner(), repo.RepoName())) } query := fmt.Sprintf(`query MapRepositoryNames { %s }`, strings.Join(queries, "")) @@ -134,13 +191,13 @@ func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames [ return nil, fmt.Errorf("failed to look up repositories: %w", err) } - repoKeys := make([]string, 0, len(repositoryNames)) + repoKeys := make([]string, 0, len(repositories)) for k := range graphqlResult { repoKeys = append(repoKeys, k) } sort.Strings(repoKeys) - result := make([]int, len(repositoryNames)) + result := make([]int, len(repositories)) for i, k := range repoKeys { result[i] = graphqlResult[k].DatabaseID } diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 77f818914..ff1796194 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -2,13 +2,10 @@ package set import ( "encoding/base64" - "errors" "fmt" "io" "io/ioutil" "net/http" - "regexp" - "strings" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" @@ -34,6 +31,7 @@ type SetOptions struct { SecretName string OrgName string EnvName string + UserSecrets bool Body string Visibility string RepositoryNames []string @@ -49,25 +47,39 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command cmd := &cobra.Command{ Use: "set ", Short: "Create or update secrets", - Long: "Locally encrypt a new or updated secret at either the repository, environment, or organization level and send it to GitHub for storage.", + Long: heredoc.Doc(` + Set a value for a secret on one of the following levels: + - repository (default): available to Actions runs in a repository + - environment: available to Actions runs for a deployment environment in a repository + - organization: available to Actions runs within an organization + - user: available to Codespaces for your user + + Organization and user secrets can optionally be restricted to only be available to + specific repositories. + + Secret values are locally encrypted before being sent to GitHub. + `), Example: heredoc.Doc(` - Paste secret in prompt + # Paste secret value for the current repository in an interactive prompt $ gh secret set MYSECRET - Use environment variable as secret value - $ gh secret set MYSECRET -b"${ENV_VALUE}" + # Read secret value from an environment variable + $ gh secret set MYSECRET --body "$ENV_VALUE" - Use file as secret value - $ gh secret set MYSECRET < file.json + # Read secret value from a file + $ gh secret set MYSECRET < myfile.txt - Set environment level secret - $ gh secret set MYSECRET -bval --env=anEnv + # Set secret for a deployment environment in the current repository + $ gh secret set MYSECRET --env myenvironment - Set organization level secret visible to entire organization - $ gh secret set MYSECRET -bval --org=anOrg --visibility=all + # Set organization-level secret visible to both public and private repositories + $ gh secret set MYSECRET --org myOrg --visibility all - Set organization level secret visible only to certain repositories - $ gh secret set MYSECRET -bval --org=anOrg --repos="repo1,repo2,repo3" + # Set organization-level secret visible to specific repositories + $ gh secret set MYSECRET --org myOrg --repos repo1,repo2,repo3 + + # Set user-level secret for Codespaces + $ gh secret set MYSECRET --user `), Args: func(cmd *cobra.Command, args []string) error { if len(args) != 1 { @@ -79,35 +91,30 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil { + if err := cmdutil.MutuallyExclusive("specify only one of `--org`, `--env`, or `--user`", opts.OrgName != "", opts.EnvName != "", opts.UserSecrets); err != nil { return err } opts.SecretName = args[0] - err := validSecretName(opts.SecretName) - if err != nil { - return err - } - if cmd.Flags().Changed("visibility") { if opts.OrgName == "" { - return cmdutil.FlagErrorf("--visibility not supported for repository secrets; did you mean to pass --org?") + return cmdutil.FlagErrorf("`--visibility` is only supported with `--org`") } if opts.Visibility != shared.All && opts.Visibility != shared.Private && opts.Visibility != shared.Selected { - return cmdutil.FlagErrorf("--visibility must be one of `all`, `private`, or `selected`") + return cmdutil.FlagErrorf("`--visibility` must be one of \"all\", \"private\", or \"selected\"") } - if opts.Visibility != shared.Selected && cmd.Flags().Changed("repos") { - return cmdutil.FlagErrorf("--repos only supported when --visibility='selected'") + if opts.Visibility != shared.Selected && len(opts.RepositoryNames) > 0 { + return cmdutil.FlagErrorf("`--repos` is only supported with `--visibility=selected`") } - if opts.Visibility == shared.Selected && !cmd.Flags().Changed("repos") { - return cmdutil.FlagErrorf("--repos flag required when --visibility='selected'") + if opts.Visibility == shared.Selected && len(opts.RepositoryNames) == 0 { + return cmdutil.FlagErrorf("`--repos` list required with `--visibility=selected`") } } else { - if cmd.Flags().Changed("repos") { + if len(opts.RepositoryNames) > 0 { opts.Visibility = shared.Selected } } @@ -119,11 +126,13 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command return setRun(opts) }, } - cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set a secret for an organization") - cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set a secret for an environment") - cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`") - cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility") - cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "A value for the secret. Reads from STDIN if not specified.") + + cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set `organization` secret") + cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set deployment `environment` secret") + cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "Set a secret for your user") + cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `{all|private|selected}`") + cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of `repositories` that can access an organization or user secret") + cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "The value for the secret (reads from standard input if not specified)") return cmd } @@ -144,7 +153,7 @@ func setRun(opts *SetOptions) error { envName := opts.EnvName var baseRepo ghrepo.Interface - if orgName == "" { + if orgName == "" && !opts.UserSecrets { baseRepo, err = opts.BaseRepo() if err != nil { return fmt.Errorf("could not determine base repo: %w", err) @@ -166,6 +175,8 @@ func setRun(opts *SetOptions) error { pk, err = getOrgPublicKey(client, host, orgName) } else if envName != "" { pk, err = getEnvPubKey(client, baseRepo, envName) + } else if opts.UserSecrets { + pk, err = getUserPublicKey(client, host) } else { pk, err = getRepoPubKey(client, baseRepo) } @@ -184,6 +195,8 @@ func setRun(opts *SetOptions) error { err = putOrgSecret(client, host, pk, *opts, encoded) } else if envName != "" { err = putEnvSecret(client, pk, baseRepo, envName, opts.SecretName, encoded) + } else if opts.UserSecrets { + err = putUserSecret(client, host, pk, *opts, encoded) } else { err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded) } @@ -193,7 +206,9 @@ func setRun(opts *SetOptions) error { if opts.IO.IsStdoutTTY() { target := orgName - if orgName == "" { + if opts.UserSecrets { + target = "your user" + } else if orgName == "" { target = ghrepo.FullName(baseRepo) } cs := opts.IO.ColorScheme() @@ -203,28 +218,6 @@ func setRun(opts *SetOptions) error { return nil } -func validSecretName(name string) error { - if name == "" { - return errors.New("secret name cannot be blank") - } - - if strings.HasPrefix(name, "GITHUB_") { - return errors.New("secret name cannot begin with GITHUB_") - } - - leadingNumber := regexp.MustCompile(`^[0-9]`) - if leadingNumber.MatchString(name) { - return errors.New("secret name cannot start with a number") - } - - validChars := regexp.MustCompile(`^([0-9]|[a-z]|[A-Z]|_)+$`) - if !validChars.MatchString(name) { - return errors.New("secret name can only contain letters, numbers, and _") - } - - return nil -} - func getBody(opts *SetOptions) ([]byte, error) { if opts.Body == "" { if opts.IO.CanPrompt() { diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index a1fa19ac8..93352bdb3 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -90,6 +90,16 @@ func TestNewCmdSet(t *testing.T) { OrgName: "coolOrg", }, }, + { + name: "user with selected repos", + cli: `-u -bs -r"monalisa/coolRepo,cli/cli,github/hub" cool_secret`, + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Selected, + RepositoryNames: []string{"monalisa/coolRepo", "cli/cli", "github/hub"}, + Body: "s", + }, + }, { name: "repo", cli: `cool_secret -b"a secret"`, @@ -121,21 +131,6 @@ func TestNewCmdSet(t *testing.T) { OrgName: "coolOrg", }, }, - { - name: "bad name prefix", - cli: `GITHUB_SECRET -b"cool"`, - wantsErr: true, - }, - { - name: "leading numbers in name", - cli: `123_SECRET -b"cool"`, - wantsErr: true, - }, - { - name: "invalid characters in name", - cli: `BAD-SECRET -b"cool"`, - wantsErr: true, - }, } for _, tt := range tests { @@ -275,7 +270,7 @@ func Test_setRun_org(t *testing.T) { opts: &SetOptions{ OrgName: "UmbrellaCorporation", Visibility: shared.Selected, - RepositoryNames: []string{"birkin", "wesker"}, + RepositoryNames: []string{"birkin", "UmbrellaCorporation/wesker"}, }, wantRepositories: []int{1, 2}, }, @@ -297,7 +292,7 @@ func Test_setRun_org(t *testing.T) { if len(tt.opts.RepositoryNames) > 0 { reg.Register(httpmock.GraphQL(`query MapRepositoryNames\b`), - httpmock.StringResponse(`{"data":{"birkin":{"databaseId":1},"wesker":{"databaseId":2}}}`)) + httpmock.StringResponse(`{"data":{"repo_0001":{"databaseId":1},"repo_0002":{"databaseId":2}}}`)) } io, _, _, _ := iostreams.Test() @@ -335,6 +330,77 @@ func Test_setRun_org(t *testing.T) { } } +func Test_setRun_user(t *testing.T) { + tests := []struct { + name string + opts *SetOptions + wantVisibility shared.Visibility + wantRepositories []string + }{ + { + name: "all vis", + opts: &SetOptions{ + UserSecrets: true, + Visibility: shared.All, + }, + }, + { + name: "selected visibility", + opts: &SetOptions{ + UserSecrets: true, + Visibility: shared.Selected, + RepositoryNames: []string{"cli/cli", "github/hub"}, + }, + wantRepositories: []string{"212613049", "401025"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + + reg.Register(httpmock.REST("GET", "user/codespaces/secrets/public-key"), + httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) + + reg.Register(httpmock.REST("PUT", "user/codespaces/secrets/cool_secret"), + httpmock.StatusStringResponse(201, `{}`)) + + if len(tt.opts.RepositoryNames) > 0 { + reg.Register(httpmock.GraphQL(`query MapRepositoryNames\b`), + httpmock.StringResponse(`{"data":{"repo_0001":{"databaseId":212613049},"repo_0002":{"databaseId":401025}}}`)) + } + + io, _, _, _ := iostreams.Test() + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + tt.opts.IO = io + tt.opts.SecretName = "cool_secret" + tt.opts.Body = "a secret" + // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7 + tt.opts.RandomOverride = bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}) + + err := setRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + + data, err := ioutil.ReadAll(reg.Requests[len(reg.Requests)-1].Body) + assert.NoError(t, err) + var payload CodespacesSecretPayload + err = json.Unmarshal(data, &payload) + assert.NoError(t, err) + assert.Equal(t, payload.KeyID, "123") + assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") + assert.ElementsMatch(t, payload.Repositories, tt.wantRepositories) + }) + } +} + func Test_getBody(t *testing.T) { tests := []struct { name string