diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 0c290807..b215c70f 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -47,7 +47,9 @@ func init() { cobra.OnInitialize(initCfg) rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.jvsctl/config.yaml)") rootCmd.PersistentFlags().String("server", "", "overwrite the JVS server address") - viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")) //nolint // not expect err + rootCmd.PersistentFlags().Bool("insecure", false, "use insecure connection to JVS server") + viper.BindPFlag("server", rootCmd.PersistentFlags().Lookup("server")) //nolint // not expect err + viper.BindPFlag("insecure", rootCmd.PersistentFlags().Lookup("insecure")) //nolint // not expect err rootCmd.AddCommand(tokenCmd) } diff --git a/pkg/cli/root_test.go b/pkg/cli/root_test.go index c5e2c65a..ab1e5c3f 100644 --- a/pkg/cli/root_test.go +++ b/pkg/cli/root_test.go @@ -40,8 +40,9 @@ func TestInitCfg(t *testing.T) { initCfg() wantCfg := &config.CLIConfig{ - Version: 1, - Server: "https://example.com", + Version: 1, + Server: "https://example.com", + Authentication: &config.CLIAuthentication{}, } if diff := cmp.Diff(wantCfg, cfg); diff != "" { t.Errorf("CLI config loaded (-want,+got):\n%s", diff) diff --git a/pkg/cli/token.go b/pkg/cli/token.go index 1e94368d..46436427 100644 --- a/pkg/cli/token.go +++ b/pkg/cli/token.go @@ -15,10 +15,21 @@ package cli import ( + "context" + "crypto/tls" + "crypto/x509" "fmt" "time" "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/credentials/oauth" + "google.golang.org/protobuf/types/known/durationpb" + + jvsapis "github.com/abcxyz/jvs/apis/v0" + "github.com/abcxyz/jvs/pkg/idtoken" ) var ( @@ -35,7 +46,38 @@ var tokenCmd = &cobra.Command{ } func runTokenCmd(cmd *cobra.Command, args []string) error { - return fmt.Errorf("not implemented") + ctx := context.Background() + dialOpts, err := dialOpts() + if err != nil { + return err + } + callOpts, err := callOpts(ctx) + if err != nil { + return err + } + + // TODO(#69): Generate breakglass token w/o JVS server. + + conn, err := grpc.Dial(cfg.Server, dialOpts...) + if err != nil { + return fmt.Errorf("failed to connect to JVS service: %w", err) + } + jvsclient := jvsapis.NewJVSServiceClient(conn) + + req := &jvsapis.CreateJustificationRequest{ + Justifications: []*jvsapis.Justification{{ + Category: "explanation", + Value: tokenExplanation, + }}, + Ttl: durationpb.New(ttl), + } + resp, err := jvsclient.CreateJustification(ctx, req, callOpts...) + if err != nil { + return err + } + + _, err = cmd.OutOrStdout().Write([]byte(resp.Token)) + return err } func init() { @@ -44,3 +86,37 @@ func init() { tokenCmd.Flags().BoolVar(&breakglass, "breakglass", false, "Whether it will be a breakglass action") tokenCmd.Flags().DurationVar(&ttl, "ttl", time.Hour, "The token time-to-live duration") } + +func dialOpts() ([]grpc.DialOption, error) { + if cfg.Authentication.Insecure { + return []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}, nil + } + + // The default. + systemRoots, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("failed to load system cert pool: %w", err) + } + //nolint:gosec // We need to support TLS 1.2 for now (G402). + cred := credentials.NewTLS(&tls.Config{ + RootCAs: systemRoots, + }) + return []grpc.DialOption{grpc.WithTransportCredentials(cred)}, nil +} + +func callOpts(ctx context.Context) ([]grpc.CallOption, error) { + if cfg.Authentication.Insecure { + return nil, nil + } + + ts, err := idtoken.FromDefaultCredentials(ctx) + if err != nil { + return nil, err + } + + token, err := ts.Token() + if err != nil { + return nil, err + } + return []grpc.CallOption{grpc.PerRPCCredentials(oauth.NewOauthAccess(token))}, nil +} diff --git a/pkg/cli/token_test.go b/pkg/cli/token_test.go index c01b5ba8..a2f61c19 100644 --- a/pkg/cli/token_test.go +++ b/pkg/cli/token_test.go @@ -15,15 +15,117 @@ package cli import ( + "context" + "fmt" + "net" + "strings" "testing" + "time" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + jvsapis "github.com/abcxyz/jvs/apis/v0" + "github.com/abcxyz/jvs/pkg/config" "github.com/abcxyz/pkg/testutil" + "github.com/spf13/cobra" ) +type fakeJVS struct { + jvsapis.UnimplementedJVSServiceServer + returnErr error +} + +func (j *fakeJVS) CreateJustification(_ context.Context, req *jvsapis.CreateJustificationRequest) (*jvsapis.CreateJustificationResponse, error) { + if j.returnErr != nil { + return nil, j.returnErr + } + + if req.Justifications[0].Category != "explanation" { + return nil, fmt.Errorf("unexpected category: %q", req.Justifications[0].Category) + } + + return &jvsapis.CreateJustificationResponse{ + Token: fmt.Sprintf("tokenized(%s);ttl=%v", req.Justifications[0].Value, req.Ttl.AsDuration()), + }, nil +} + func TestRunTokenCmd(t *testing.T) { - err := runTokenCmd(nil, nil) - wantErrStr := "not implemented" - if diff := testutil.DiffErrString(err, wantErrStr); diff != "" { - t.Errorf("unexpected error: %s", diff) + t.Parallel() + + tests := []struct { + name string + jvs *fakeJVS + explanation string + wantToken string + wantErr string + }{{ + name: "success", + jvs: &fakeJVS{}, + explanation: "i-have-reason", + wantToken: fmt.Sprintf("tokenized(i-have-reason);ttl=%v", time.Minute), + }, { + name: "error", + jvs: &fakeJVS{returnErr: fmt.Errorf("server err")}, + explanation: "i-have-reason", + wantErr: "server err", + }} + + for _, tc := range tests { + // Cannot parallel because the global CLI config. + t.Run(tc.name, func(t *testing.T) { + server, _ := testFakeGRPCServer(t, func(s *grpc.Server) { jvsapis.RegisterJVSServiceServer(s, tc.jvs) }) + + // These are global flags. + cfg = &config.CLIConfig{ + Server: server, + Authentication: &config.CLIAuthentication{ + Insecure: true, + }, + } + tokenExplanation = tc.explanation + ttl = time.Minute + + buf := &strings.Builder{} + cmd := &cobra.Command{} + cmd.SetOut(buf) + + err := runTokenCmd(cmd, nil) + if diff := testutil.DiffErrString(err, tc.wantErr); diff != "" { + t.Errorf("unexpected err: %s", diff) + } + + if gotToken := buf.String(); gotToken != tc.wantToken { + t.Errorf("justification token got=%q, want=%q", gotToken, tc.wantToken) + } + }) + } +} + +// Copied over from Lumberjack. TODO: share it in pkg. +func testFakeGRPCServer(tb testing.TB, registerFunc func(*grpc.Server)) (string, *grpc.ClientConn) { + tb.Helper() + + s := grpc.NewServer() + tb.Cleanup(func() { s.GracefulStop() }) + + registerFunc(s) + + lis, err := net.Listen("tcp", "localhost:0") + if err != nil { + tb.Fatalf("net.Listen(tcp, localhost:0) failed: %v", err) + } + + go func() { + if err := s.Serve(lis); err != nil { + tb.Logf("net.Listen(tcp, localhost:0) serve failed: %v", err) + } + }() + + addr := lis.Addr().String() + conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + tb.Fatalf("failed to dail %q: %s", addr, err) } + return addr, conn } diff --git a/pkg/config/cli_config.go b/pkg/config/cli_config.go index 2dbf620d..58772b32 100644 --- a/pkg/config/cli_config.go +++ b/pkg/config/cli_config.go @@ -31,6 +31,15 @@ type CLIConfig struct { // Server is the JVS server address. Server string `yaml:"server,omitempty" env:"SERVER,overwrite"` + + // Authentication is the authentication config. + Authentication *CLIAuthentication `yaml:"authentication,omitempty"` +} + +// CLIAuthentication is the CLI authentication config. +type CLIAuthentication struct { + // Insecure indiates whether to use insecured connection to the JVS server. + Insecure bool `yaml:"insecure,omitempty"` } // Validate checks if the config is valid. @@ -50,4 +59,7 @@ func (cfg *CLIConfig) SetDefault() { if cfg.Version == 0 { cfg.Version = DefaultCLIConfigVersion } + if cfg.Authentication == nil { + cfg.Authentication = &CLIAuthentication{} + } } diff --git a/pkg/config/cli_config_test.go b/pkg/config/cli_config_test.go index a39b104c..d6368e94 100644 --- a/pkg/config/cli_config_test.go +++ b/pkg/config/cli_config_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/abcxyz/pkg/testutil" + "github.com/google/go-cmp/cmp" ) func TestValidateCLIConfig(t *testing.T) { @@ -47,3 +48,31 @@ func TestValidateCLIConfig(t *testing.T) { }) } } + +func TestSetDefault(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *CLIConfig + wantCfg *CLIConfig + }{{ + name: "default_empty_authentication", + cfg: &CLIConfig{}, + wantCfg: &CLIConfig{ + Version: 1, + Authentication: &CLIAuthentication{}, + }, + }} + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tc.cfg.SetDefault() + if diff := cmp.Diff(tc.wantCfg, tc.cfg); diff != "" { + t.Errorf("config with defaults (-want,+got):\n%s", diff) + } + }) + } +}