From c49a422e0972bb3e3504cd332212ed405e8418e5 Mon Sep 17 00:00:00 2001 From: Ben McNicholl Date: Wed, 11 Dec 2024 14:08:11 +1100 Subject: [PATCH] initial api scoping checks (#414) * initial api scoping checks * never use optional --- internal/config/token.go | 76 +++++++++++++++++++++++++ internal/scopes/scopes.go | 117 ++++++++++++++++++++++++++++++++++++++ pkg/cmd/build/new.go | 22 +++++++ 3 files changed, 215 insertions(+) create mode 100644 internal/config/token.go create mode 100644 internal/scopes/scopes.go diff --git a/internal/config/token.go b/internal/config/token.go new file mode 100644 index 00000000..61fee954 --- /dev/null +++ b/internal/config/token.go @@ -0,0 +1,76 @@ +package config + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" +) + +type AccessToken struct { + UUID string `json:"uuid"` + Scopes []string `json:"scopes"` + ClientID string `json:"client_id"` +} + +var ( + tokenInfo *AccessToken + tokenInfoOnce sync.Once +) + +// GetTokenScopes fetches and returns the scopes associated with the current API token +func (c *Config) GetTokenScopes() []string { + var err error + tokenInfoOnce.Do(func() { + tokenInfo, err = c.fetchTokenInfo(context.Background()) + }) + + if err != nil { + // Log the error but don't expose it to the caller + // as this is called in the middle of command execution + log.Printf("Error fetching token info: %v", err) + return nil + } + + if tokenInfo == nil { + return nil + } + + return tokenInfo.Scopes +} + +// fetchTokenInfo retrieves the token information from the Buildkite API +func (c *Config) fetchTokenInfo(ctx context.Context) (*AccessToken, error) { + req, err := http.NewRequestWithContext( + ctx, + "GET", + fmt.Sprintf("%s/v2/access-token", c.RESTAPIEndpoint()), + nil, + ) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.APIToken())) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching token info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var token AccessToken + if err := json.NewDecoder(resp.Body).Decode(&token); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return &token, nil +} diff --git a/internal/scopes/scopes.go b/internal/scopes/scopes.go new file mode 100644 index 00000000..cf087c31 --- /dev/null +++ b/internal/scopes/scopes.go @@ -0,0 +1,117 @@ +package scopes + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +type Scope string + +const ( + // Agent scopes + ReadAgents Scope = "read_agents" + WriteAgents Scope = "write_agents" + + // Cluster scopes + ReadClusters Scope = "read_clusters" + WriteClusters Scope = "write_clusters" + + // Team scopes + ReadTeams Scope = "read_teams" + WriteTeams Scope = "write_teams" + + // Artifact scopes + ReadArtifacts Scope = "read_artifacts" + WriteArtifacts Scope = "write_artifacts" + + // Build scopes + ReadBuilds Scope = "read_builds" + WriteBuilds Scope = "write_builds" + + // Build logs and environment scopes + ReadJobEnv Scope = "read_job_env" + ReadBuildLogs Scope = "read_build_logs" + WriteBuildLogs Scope = "write_build_logs" + + // Organization scopes + ReadOrganizations Scope = "read_organizations" + + // Pipeline scopes + ReadPipelines Scope = "read_pipelines" + WritePipelines Scope = "write_pipelines" + + // Pipeline template scopes + ReadPipelineTemplates Scope = "read_pipeline_templates" + WritePipelineTemplates Scope = "write_pipeline_templates" + + // Rule scopes + ReadRules Scope = "read_rules" + WriteRules Scope = "write_rules" + + // User scopes + ReadUser Scope = "read_user" + + // Test suite scopes + ReadSuites Scope = "read_suites" + WriteSuites Scope = "write_suites" + + // Test plan scopes + ReadTestPlan Scope = "read_test_plan" + WriteTestPlan Scope = "write_test_plan" + + // Registry scopes + ReadRegistries Scope = "read_registries" + WriteRegistries Scope = "write_registries" + DeleteRegistries Scope = "delete_registries" + + // Package scopes + ReadPackages Scope = "read_packages" + WritePackages Scope = "write_packages" + DeletePackages Scope = "delete_packages" + + // GraphQL scope + GraphQL Scope = "graphql" +) + +type CommandScopes struct { + Required []Scope +} + +func GetCommandScopes(cmd *cobra.Command) CommandScopes { + required := []Scope{} + + if reqScopes, ok := cmd.Annotations["requiredScopes"]; ok { + for _, scope := range strings.Split(reqScopes, ",") { + required = append(required, Scope(strings.TrimSpace(scope))) + } + } + + return CommandScopes{ + Required: required, + } +} + +func ValidateScopes(cmdScopes CommandScopes, tokenScopes []string) error { + missingRequired := []string{} + + for _, requiredScope := range cmdScopes.Required { + found := false + for _, tokenScope := range tokenScopes { + if string(requiredScope) == tokenScope { + found = true + break + } + } + if !found { + missingRequired = append(missingRequired, string(requiredScope)) + } + } + + if len(missingRequired) > 0 { + return fmt.Errorf("missing required scopes: %s", strings.Join(missingRequired, ", ")) + } + + return nil +} diff --git a/pkg/cmd/build/new.go b/pkg/cmd/build/new.go index d4535ff2..b9fb7c64 100644 --- a/pkg/cmd/build/new.go +++ b/pkg/cmd/build/new.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/buildkite/cli/v3/internal/io" "github.com/buildkite/cli/v3/internal/pipeline/resolver" + "github.com/buildkite/cli/v3/internal/scopes" "github.com/buildkite/cli/v3/internal/util" "github.com/buildkite/cli/v3/pkg/cmd/factory" "github.com/buildkite/go-buildkite/v4" @@ -46,6 +47,23 @@ func NewCmdBuildNew(f *factory.Factory) *cobra.Command { $ bk build new -e "FOO=BAR" -e "BAR=BAZ" `), + PreRunE: func(cmd *cobra.Command, args []string) error { + // Get the command's required and optional scopes + cmdScopes := scopes.GetCommandScopes(cmd) + + // Get the token scopes from the factory + tokenScopes := f.Config.GetTokenScopes() + if len(tokenScopes) == 0 { + return fmt.Errorf("no scopes found in token. Please ensure you're using a token with appropriate scopes") + } + + // Validate the scopes + if err := scopes.ValidateScopes(cmdScopes, tokenScopes); err != nil { + return err + } + + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { resolvers := resolver.NewAggregateResolver( resolver.ResolveFromFlag(pipeline, f.Config), @@ -90,6 +108,10 @@ func NewCmdBuildNew(f *factory.Factory) *cobra.Command { }, } + cmd.Annotations = map[string]string{ + "requiredScopes": string(scopes.WriteBuilds), + } + cmd.Flags().StringVarP(&message, "message", "m", "", "Description of the build. If left blank, the commit message will be used once the build starts.") cmd.Flags().StringVarP(&commit, "commit", "c", "HEAD", "The commit to build.") cmd.Flags().StringVarP(&branch, "branch", "b", "", "The branch to build. Defaults to the default branch of the pipeline.")