Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/cli/cmd/organization.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func newOrganizationCmd() *cobra.Command {
newOrganizationLeaveCmd(),
newOrganizationDescribeCmd(),
newOrganizationInvitationCmd(),
newOrganizationAPITokenCmd(),
)
return cmd
}
34 changes: 34 additions & 0 deletions app/cli/cmd/organization_apitoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// Copyright 2023 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"github.com/spf13/cobra"
)

func newOrganizationAPITokenCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "api-token",
Aliases: []string{"token"},
Short: "API token management",
Long: `Manage API tokens to authenticate with the Chainloop API.
NOTE: They are not meant to be used during the attestation process, for that purpose you'll need to use a robot accounts instead.`,
}

cmd.AddCommand(newAPITokenCreateCmd(), newAPITokenListCmd(), newAPITokenRevokeCmd())

return cmd
}
90 changes: 90 additions & 0 deletions app/cli/cmd/organization_apitoken_create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// Copyright 2023 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"fmt"
"time"

"github.com/chainloop-dev/chainloop/app/cli/internal/action"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)

func newAPITokenCreateCmd() *cobra.Command {
var (
description string
expiresIn time.Duration
)

cmd := &cobra.Command{
Use: "create",
Short: "Create an API token",
RunE: func(cmd *cobra.Command, args []string) error {
var duration *time.Duration
if expiresIn != 0 {
duration = &expiresIn
}

res, err := action.NewAPITokenCreate(actionOpts).Run(context.Background(), description, duration)
if err != nil {
return fmt.Errorf("creating API token: %w", err)
}

return encodeOutput([]*action.APITokenItem{res}, apiTokenListTableOutput)
},
}

cmd.Flags().StringVar(&description, "description", "", "API token description")
cmd.Flags().DurationVar(&expiresIn, "expiration", 0, "optional API token expiration, in hours i.e 1h, 24h, 178h (week), ...")

return cmd
}

func apiTokenListTableOutput(tokens []*action.APITokenItem) error {
if len(tokens) == 0 {
fmt.Println("there are no API tokens in this org")
return nil
}

t := newTableWriter()

t.AppendHeader(table.Row{"ID", "Description", "Created At", "Expires At", "Revoked At"})
for _, p := range tokens {
r := table.Row{p.ID, p.Description, p.CreatedAt.Format(time.RFC822)}
if p.ExpiresAt != nil {
r = append(r, p.ExpiresAt.Format(time.RFC822))
} else {
r = append(r, "")
}

if p.RevokedAt != nil {
fmt.Println("revoked at", p.RevokedAt.Format(time.RFC822))
r = append(r, p.RevokedAt.Format(time.RFC822))
}

t.AppendRow(r)
}
t.Render()

if len(tokens) == 1 && tokens[0].JWT != "" {
// Output the token too
fmt.Printf("\nSave the following token since it will not printed again: \n\n %s\n\n", tokens[0].JWT)
}

return nil
}
45 changes: 45 additions & 0 deletions app/cli/cmd/organization_apitoken_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// Copyright 2023 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"fmt"

"github.com/chainloop-dev/chainloop/app/cli/internal/action"
"github.com/spf13/cobra"
)

func newAPITokenListCmd() *cobra.Command {
var includeRevoked bool

cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "List API tokens in this organization",
RunE: func(cmd *cobra.Command, args []string) error {
res, err := action.NewAPITokenList(actionOpts).Run(context.Background(), includeRevoked)
if err != nil {
return fmt.Errorf("listing API tokens: %w", err)
}

return encodeOutput(res, apiTokenListTableOutput)
},
}

cmd.Flags().BoolVarP(&includeRevoked, "all", "a", false, "show all API tokens including revoked ones")
return cmd
}
47 changes: 47 additions & 0 deletions app/cli/cmd/organization_apitoken_revoke.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// Copyright 2023 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package cmd

import (
"context"
"fmt"

"github.com/chainloop-dev/chainloop/app/cli/internal/action"
"github.com/spf13/cobra"
)

func newAPITokenRevokeCmd() *cobra.Command {
var apiTokenID string

cmd := &cobra.Command{
Use: "revoke",
Short: "revoke API token",
RunE: func(cmd *cobra.Command, args []string) error {
if err := action.NewAPITokenRevoke(actionOpts).Run(context.Background(), apiTokenID); err != nil {
return fmt.Errorf("revoking API token: %w", err)
}

logger.Info().Msg("API token revoked!")
return nil
},
}

cmd.Flags().StringVar(&apiTokenID, "id", "", "API token ID")
err := cmd.MarkFlagRequired("id")
cobra.CheckErr(err)

return cmd
}
3 changes: 2 additions & 1 deletion app/cli/cmd/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ type tabulatedData interface {
[]*action.AttachedIntegrationItem |
[]*action.MembershipItem |
[]*action.CASBackendItem |
[]*action.OrgInvitationItem
[]*action.OrgInvitationItem |
[]*action.APITokenItem
}

var ErrOutputFormatNotImplemented = errors.New("format not implemented")
Expand Down
69 changes: 50 additions & 19 deletions app/cli/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,15 @@ var (
logger zerolog.Logger
defaultCPAPI = "api.cp.chainloop.dev:443"
defaultCASAPI = "api.cas.chainloop.dev:443"
apiToken string
)

const useWorkflowRobotAccount = "withWorkflowRobotAccount"
const appName = "chainloop"
const (
useWorkflowRobotAccount = "withWorkflowRobotAccount"
appName = "chainloop"
//nolint:gosec
apiTokenEnvVarName = "CHAINLOOP_API_TOKEN"
)

func NewRootCmd(l zerolog.Logger) *cobra.Command {
rootCmd := &cobra.Command{
Expand All @@ -51,33 +56,24 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
SilenceErrors: true,
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
logger.Debug().Str("path", viper.ConfigFileUsed()).Msg("using config file")

var err error
logger, err = initLogger(l)
if err != nil {
return err
}

logger.Debug().Str("path", viper.ConfigFileUsed()).Msg("using config file")

// Some actions do not need authentication headers
storedToken := viper.GetString(confOptions.authToken.viperKey)

// If the CMD uses a workflow robot account instead of the regular Auth token we override it
// TODO: the attestation CLI should get split from this one
if _, ok := cmd.Annotations[useWorkflowRobotAccount]; ok {
storedToken = robotAccount
if storedToken != "" {
logger.Debug().Msg("loaded token from robot account")
} else {
return newGracefulError(ErrRobotAccountRequired)
}
}

if flagInsecure {
logger.Warn().Msg("API contacted in insecure mode")
}

conn, err := grpcconn.New(viper.GetString(confOptions.controlplaneAPI.viperKey), storedToken, flagInsecure)
apiToken, err := loadControlplaneAuthToken(cmd)
if err != nil {
return fmt.Errorf("loading controlplane auth token: %w", err)
}

conn, err := grpcconn.New(viper.GetString(confOptions.controlplaneAPI.viperKey), apiToken, flagInsecure)
if err != nil {
return err
}
Expand Down Expand Up @@ -107,6 +103,14 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
rootCmd.PersistentFlags().BoolVar(&flagDebug, "debug", false, "Enable debug/verbose logging mode")
rootCmd.PersistentFlags().StringVarP(&flagOutputFormat, "output", "o", "table", "Output format, valid options are json and table")

// Override the oauth authentication requirement for the CLI by providing an API token
rootCmd.PersistentFlags().StringVarP(&apiToken, "token", "t", "", fmt.Sprintf("API token. NOTE: Alternatively use the env variable %s", apiTokenEnvVarName))
// We do not use viper in this case because we do not want this token to be saved in the config file
// Instead we load the env variable manually
if apiToken == "" {
apiToken = os.Getenv(apiTokenEnvVarName)
}

rootCmd.AddCommand(newWorkflowCmd(), newAuthCmd(), NewVersionCmd(),
newAttestationCmd(), newArtifactCmd(), newConfigCmd(),
newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(),
Expand Down Expand Up @@ -182,3 +186,30 @@ func cleanup(conn *grpc.ClientConn) error {
}
return nil
}

// Load the controlplane based on the following order:
// 1. If the CMD uses a robot account instead of the regular auth token we override it
// 2. If the CMD uses an API token flag/env variable we override it
// 3. If the CMD uses a config file we load it from there
func loadControlplaneAuthToken(cmd *cobra.Command) (string, error) {
// If the CMD uses a robot account instead of the regular auth token we override it
// TODO: the attestation CLI should get split from this one
if _, ok := cmd.Annotations[useWorkflowRobotAccount]; ok {
if robotAccount != "" {
logger.Debug().Msg("loaded token from robot account")
} else {
return "", newGracefulError(ErrRobotAccountRequired)
}

return robotAccount, nil
}

// override if token is passed as a flag/env variable
if apiToken != "" {
logger.Info().Msg("API token provided to the command line")
return apiToken, nil
}

// loaded from config file, previously stored via "auth login"
return viper.GetString(confOptions.authToken.viperKey), nil
}
Loading