Skip to content

Commit df8e477

Browse files
authored
API tokens API support (#461)
Signed-off-by: Miguel Martinez Trivino <[email protected]>
1 parent 71cdc5f commit df8e477

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+4189
-155
lines changed

app/cli/cmd/organization.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func newOrganizationCmd() *cobra.Command {
3434
newOrganizationLeaveCmd(),
3535
newOrganizationDescribeCmd(),
3636
newOrganizationInvitationCmd(),
37+
newOrganizationAPITokenCmd(),
3738
)
3839
return cmd
3940
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package cmd
17+
18+
import (
19+
"github.com/spf13/cobra"
20+
)
21+
22+
func newOrganizationAPITokenCmd() *cobra.Command {
23+
cmd := &cobra.Command{
24+
Use: "api-token",
25+
Aliases: []string{"token"},
26+
Short: "API token management",
27+
Long: `Manage API tokens to authenticate with the Chainloop API.
28+
NOTE: They are not meant to be used during the attestation process, for that purpose you'll need to use a robot accounts instead.`,
29+
}
30+
31+
cmd.AddCommand(newAPITokenCreateCmd(), newAPITokenListCmd(), newAPITokenRevokeCmd())
32+
33+
return cmd
34+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package cmd
17+
18+
import (
19+
"context"
20+
"fmt"
21+
"time"
22+
23+
"github.com/chainloop-dev/chainloop/app/cli/internal/action"
24+
"github.com/jedib0t/go-pretty/v6/table"
25+
"github.com/spf13/cobra"
26+
)
27+
28+
func newAPITokenCreateCmd() *cobra.Command {
29+
var (
30+
description string
31+
expiresIn time.Duration
32+
)
33+
34+
cmd := &cobra.Command{
35+
Use: "create",
36+
Short: "Create an API token",
37+
RunE: func(cmd *cobra.Command, args []string) error {
38+
var duration *time.Duration
39+
if expiresIn != 0 {
40+
duration = &expiresIn
41+
}
42+
43+
res, err := action.NewAPITokenCreate(actionOpts).Run(context.Background(), description, duration)
44+
if err != nil {
45+
return fmt.Errorf("creating API token: %w", err)
46+
}
47+
48+
return encodeOutput([]*action.APITokenItem{res}, apiTokenListTableOutput)
49+
},
50+
}
51+
52+
cmd.Flags().StringVar(&description, "description", "", "API token description")
53+
cmd.Flags().DurationVar(&expiresIn, "expiration", 0, "optional API token expiration, in hours i.e 1h, 24h, 178h (week), ...")
54+
55+
return cmd
56+
}
57+
58+
func apiTokenListTableOutput(tokens []*action.APITokenItem) error {
59+
if len(tokens) == 0 {
60+
fmt.Println("there are no API tokens in this org")
61+
return nil
62+
}
63+
64+
t := newTableWriter()
65+
66+
t.AppendHeader(table.Row{"ID", "Description", "Created At", "Expires At", "Revoked At"})
67+
for _, p := range tokens {
68+
r := table.Row{p.ID, p.Description, p.CreatedAt.Format(time.RFC822)}
69+
if p.ExpiresAt != nil {
70+
r = append(r, p.ExpiresAt.Format(time.RFC822))
71+
} else {
72+
r = append(r, "")
73+
}
74+
75+
if p.RevokedAt != nil {
76+
fmt.Println("revoked at", p.RevokedAt.Format(time.RFC822))
77+
r = append(r, p.RevokedAt.Format(time.RFC822))
78+
}
79+
80+
t.AppendRow(r)
81+
}
82+
t.Render()
83+
84+
if len(tokens) == 1 && tokens[0].JWT != "" {
85+
// Output the token too
86+
fmt.Printf("\nSave the following token since it will not printed again: \n\n %s\n\n", tokens[0].JWT)
87+
}
88+
89+
return nil
90+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package cmd
17+
18+
import (
19+
"context"
20+
"fmt"
21+
22+
"github.com/chainloop-dev/chainloop/app/cli/internal/action"
23+
"github.com/spf13/cobra"
24+
)
25+
26+
func newAPITokenListCmd() *cobra.Command {
27+
var includeRevoked bool
28+
29+
cmd := &cobra.Command{
30+
Use: "list",
31+
Aliases: []string{"ls"},
32+
Short: "List API tokens in this organization",
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
res, err := action.NewAPITokenList(actionOpts).Run(context.Background(), includeRevoked)
35+
if err != nil {
36+
return fmt.Errorf("listing API tokens: %w", err)
37+
}
38+
39+
return encodeOutput(res, apiTokenListTableOutput)
40+
},
41+
}
42+
43+
cmd.Flags().BoolVarP(&includeRevoked, "all", "a", false, "show all API tokens including revoked ones")
44+
return cmd
45+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package cmd
17+
18+
import (
19+
"context"
20+
"fmt"
21+
22+
"github.com/chainloop-dev/chainloop/app/cli/internal/action"
23+
"github.com/spf13/cobra"
24+
)
25+
26+
func newAPITokenRevokeCmd() *cobra.Command {
27+
var apiTokenID string
28+
29+
cmd := &cobra.Command{
30+
Use: "revoke",
31+
Short: "revoke API token",
32+
RunE: func(cmd *cobra.Command, args []string) error {
33+
if err := action.NewAPITokenRevoke(actionOpts).Run(context.Background(), apiTokenID); err != nil {
34+
return fmt.Errorf("revoking API token: %w", err)
35+
}
36+
37+
logger.Info().Msg("API token revoked!")
38+
return nil
39+
},
40+
}
41+
42+
cmd.Flags().StringVar(&apiTokenID, "id", "", "API token ID")
43+
err := cmd.MarkFlagRequired("id")
44+
cobra.CheckErr(err)
45+
46+
return cmd
47+
}

app/cli/cmd/output.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ type tabulatedData interface {
4444
[]*action.AttachedIntegrationItem |
4545
[]*action.MembershipItem |
4646
[]*action.CASBackendItem |
47-
[]*action.OrgInvitationItem
47+
[]*action.OrgInvitationItem |
48+
[]*action.APITokenItem
4849
}
4950

5051
var ErrOutputFormatNotImplemented = errors.New("format not implemented")

app/cli/cmd/root.go

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,15 @@ var (
3939
logger zerolog.Logger
4040
defaultCPAPI = "api.cp.chainloop.dev:443"
4141
defaultCASAPI = "api.cas.chainloop.dev:443"
42+
apiToken string
4243
)
4344

44-
const useWorkflowRobotAccount = "withWorkflowRobotAccount"
45-
const appName = "chainloop"
45+
const (
46+
useWorkflowRobotAccount = "withWorkflowRobotAccount"
47+
appName = "chainloop"
48+
//nolint:gosec
49+
apiTokenEnvVarName = "CHAINLOOP_API_TOKEN"
50+
)
4651

4752
func NewRootCmd(l zerolog.Logger) *cobra.Command {
4853
rootCmd := &cobra.Command{
@@ -51,33 +56,24 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
5156
SilenceErrors: true,
5257
SilenceUsage: true,
5358
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
59+
logger.Debug().Str("path", viper.ConfigFileUsed()).Msg("using config file")
60+
5461
var err error
5562
logger, err = initLogger(l)
5663
if err != nil {
5764
return err
5865
}
5966

60-
logger.Debug().Str("path", viper.ConfigFileUsed()).Msg("using config file")
61-
62-
// Some actions do not need authentication headers
63-
storedToken := viper.GetString(confOptions.authToken.viperKey)
64-
65-
// If the CMD uses a workflow robot account instead of the regular Auth token we override it
66-
// TODO: the attestation CLI should get split from this one
67-
if _, ok := cmd.Annotations[useWorkflowRobotAccount]; ok {
68-
storedToken = robotAccount
69-
if storedToken != "" {
70-
logger.Debug().Msg("loaded token from robot account")
71-
} else {
72-
return newGracefulError(ErrRobotAccountRequired)
73-
}
74-
}
75-
7667
if flagInsecure {
7768
logger.Warn().Msg("API contacted in insecure mode")
7869
}
7970

80-
conn, err := grpcconn.New(viper.GetString(confOptions.controlplaneAPI.viperKey), storedToken, flagInsecure)
71+
apiToken, err := loadControlplaneAuthToken(cmd)
72+
if err != nil {
73+
return fmt.Errorf("loading controlplane auth token: %w", err)
74+
}
75+
76+
conn, err := grpcconn.New(viper.GetString(confOptions.controlplaneAPI.viperKey), apiToken, flagInsecure)
8177
if err != nil {
8278
return err
8379
}
@@ -107,6 +103,14 @@ func NewRootCmd(l zerolog.Logger) *cobra.Command {
107103
rootCmd.PersistentFlags().BoolVar(&flagDebug, "debug", false, "Enable debug/verbose logging mode")
108104
rootCmd.PersistentFlags().StringVarP(&flagOutputFormat, "output", "o", "table", "Output format, valid options are json and table")
109105

106+
// Override the oauth authentication requirement for the CLI by providing an API token
107+
rootCmd.PersistentFlags().StringVarP(&apiToken, "token", "t", "", fmt.Sprintf("API token. NOTE: Alternatively use the env variable %s", apiTokenEnvVarName))
108+
// We do not use viper in this case because we do not want this token to be saved in the config file
109+
// Instead we load the env variable manually
110+
if apiToken == "" {
111+
apiToken = os.Getenv(apiTokenEnvVarName)
112+
}
113+
110114
rootCmd.AddCommand(newWorkflowCmd(), newAuthCmd(), NewVersionCmd(),
111115
newAttestationCmd(), newArtifactCmd(), newConfigCmd(),
112116
newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(),
@@ -182,3 +186,30 @@ func cleanup(conn *grpc.ClientConn) error {
182186
}
183187
return nil
184188
}
189+
190+
// Load the controlplane based on the following order:
191+
// 1. If the CMD uses a robot account instead of the regular auth token we override it
192+
// 2. If the CMD uses an API token flag/env variable we override it
193+
// 3. If the CMD uses a config file we load it from there
194+
func loadControlplaneAuthToken(cmd *cobra.Command) (string, error) {
195+
// If the CMD uses a robot account instead of the regular auth token we override it
196+
// TODO: the attestation CLI should get split from this one
197+
if _, ok := cmd.Annotations[useWorkflowRobotAccount]; ok {
198+
if robotAccount != "" {
199+
logger.Debug().Msg("loaded token from robot account")
200+
} else {
201+
return "", newGracefulError(ErrRobotAccountRequired)
202+
}
203+
204+
return robotAccount, nil
205+
}
206+
207+
// override if token is passed as a flag/env variable
208+
if apiToken != "" {
209+
logger.Info().Msg("API token provided to the command line")
210+
return apiToken, nil
211+
}
212+
213+
// loaded from config file, previously stored via "auth login"
214+
return viper.GetString(confOptions.authToken.viperKey), nil
215+
}

0 commit comments

Comments
 (0)