diff --git a/tool/tctl/common/cmds.go b/tool/tctl/common/cmds.go index 2cd7b7a579802..bf8b292381542 100644 --- a/tool/tctl/common/cmds.go +++ b/tool/tctl/common/cmds.go @@ -20,6 +20,7 @@ package common import ( "github.com/gravitational/teleport/tool/tctl/common/accessmonitoring" + "github.com/gravitational/teleport/tool/tctl/common/decision" "github.com/gravitational/teleport/tool/tctl/common/loginrule" "github.com/gravitational/teleport/tool/tctl/common/plugin" "github.com/gravitational/teleport/tool/tctl/sso/configure" @@ -67,5 +68,6 @@ func Commands() []CLICommand { &touchIDCommand{}, &TerraformCommand{}, &AutoUpdateCommand{}, + &decision.Command{}, } } diff --git a/tool/tctl/common/decision/command.go b/tool/tctl/common/decision/command.go new file mode 100644 index 0000000000000..ab043f60cde12 --- /dev/null +++ b/tool/tctl/common/decision/command.go @@ -0,0 +1,78 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package decision + +import ( + "context" + "io" + "os" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1" + "github.com/gravitational/teleport/lib/service/servicecfg" + commonclient "github.com/gravitational/teleport/tool/tctl/common/client" + tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config" +) + +// Command is a group of commands to interact with the Teleport Decision Service. +type Command struct { + // Output is the writer that any command output should be written to. + Output io.Writer + + evaluateSSHCommand EvaluateSSHCommand + evaluateDatabaseCommand EvaluateDatabaseCommand +} + +// Initialize sets up the "tctl decision" command. +func (c *Command) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, _ *servicecfg.Config) { + if c.Output == nil { + c.Output = os.Stdout + } + + cmd := app.Command("decision", "Interact with the Teleport Decision Service.").Hidden() + c.evaluateSSHCommand.Initialize(cmd, c.Output) + c.evaluateDatabaseCommand.Initialize(cmd, c.Output) +} + +// Client contains methods required by this command +// to interact with the control plane. +type Client interface { + DecisionClient() decisionpb.DecisionServiceClient +} + +// TryRun attempts to run subcommands. +func (c *Command) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (bool, error) { + var run func(context.Context, Client) error + switch cmd { + case c.evaluateSSHCommand.FullCommand(): + run = c.evaluateSSHCommand.Run + case c.evaluateDatabaseCommand.FullCommand(): + run = c.evaluateDatabaseCommand.Run + default: + return false, nil + } + + client, closeFn, err := clientFunc(ctx) + if err != nil { + return true, trace.Wrap(err) + } + + defer func() { closeFn(ctx) }() + return true, trace.Wrap(run(ctx, client)) +} diff --git a/tool/tctl/common/decision/command_test.go b/tool/tctl/common/decision/command_test.go new file mode 100644 index 0000000000000..20865d0867bc3 --- /dev/null +++ b/tool/tctl/common/decision/command_test.go @@ -0,0 +1,84 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package decision_test + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/alecthomas/kingpin/v2" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + + decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/tool/tctl/common/decision" +) + +type fakeDecisionService struct { + decision.Client + + decisionClient decisionpb.DecisionServiceClient +} + +func (f fakeDecisionService) DecisionClient() decisionpb.DecisionServiceClient { + return f.decisionClient +} + +type fakeDecisionServiceClient struct { + decisionpb.DecisionServiceClient + + sshResponse *decisionpb.EvaluateSSHAccessResponse + databaseResponse *decisionpb.EvaluateDatabaseAccessResponse +} + +// EvaluateSSHAccess evaluates an SSH access attempt. +func (f fakeDecisionServiceClient) EvaluateSSHAccess(ctx context.Context, in *decisionpb.EvaluateSSHAccessRequest, opts ...grpc.CallOption) (*decisionpb.EvaluateSSHAccessResponse, error) { + return f.sshResponse, nil +} + +// EvaluateDatabaseAccess evaluate a database access attempt. +func (f fakeDecisionServiceClient) EvaluateDatabaseAccess(ctx context.Context, in *decisionpb.EvaluateDatabaseAccessRequest, opts ...grpc.CallOption) (*decisionpb.EvaluateDatabaseAccessResponse, error) { + return f.databaseResponse, nil +} + +func TestCommands(t *testing.T) { + var output bytes.Buffer + cmd := decision.Command{Output: &output} + + cmd.Initialize(kingpin.New("tctl", "test"), nil, nil) + + match, err := cmd.TryRun(context.Background(), "decision evaluate-ssh-access", func(ctx context.Context) (client *authclient.Client, close func(context.Context), err error) { + return nil, nil, errors.New("fail") + }) + assert.True(t, match, "evaluate SSH command did not match") + assert.Error(t, err, "expected failure from init function") + + match, err = cmd.TryRun(context.Background(), "decision evaluate-db-access", func(ctx context.Context) (client *authclient.Client, close func(context.Context), err error) { + return nil, nil, errors.New("fail") + }) + assert.True(t, match, "evaluate database command did not match") + assert.Error(t, err, "expected failure from init function") + + match, err = cmd.TryRun(context.Background(), "decision evaluate-foo", func(ctx context.Context) (client *authclient.Client, close func(context.Context), err error) { + return nil, nil, errors.New("fail") + }) + assert.False(t, match, "evaluate foo command matched") + assert.NoError(t, err, "error received when no command matched") +} diff --git a/tool/tctl/common/decision/evaluate_db_command.go b/tool/tctl/common/decision/evaluate_db_command.go new file mode 100644 index 0000000000000..a1847f70ba279 --- /dev/null +++ b/tool/tctl/common/decision/evaluate_db_command.go @@ -0,0 +1,75 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +package decision + +import ( + "context" + "io" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" + decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/utils" +) + +// EvaluateDatabaseCommand is a command to evaluate +// database access via the Teleport Decision Service. +type EvaluateDatabaseCommand struct { + output io.Writer + databaseDetails databaseDetails + command *kingpin.CmdClause +} + +type databaseDetails struct { + databaseID string +} + +// Initialize sets up the "tctl decision evaluate db" command. +func (c *EvaluateDatabaseCommand) Initialize(cmd *kingpin.CmdClause, output io.Writer) { + c.output = output + c.command = cmd.Command("evaluate-db-access", "Evaluate database access for a user.").Hidden() + c.command.Flag("database-id", "The id of the target database.").StringVar(&c.databaseDetails.databaseID) +} + +// FullCommand returns the fully qualified name of +// the subcommand, i.e. tctl decision evaluate db. +func (c *EvaluateDatabaseCommand) FullCommand() string { + return c.command.FullCommand() +} + +// Run executes the subcommand. +func (c *EvaluateDatabaseCommand) Run(ctx context.Context, clt Client) error { + resp, err := clt.DecisionClient().EvaluateDatabaseAccess(ctx, &decisionpb.EvaluateDatabaseAccessRequest{ + Metadata: &decisionpb.RequestMetadata{PepVersionHint: teleport.Version}, + TlsIdentity: &decisionpb.TLSIdentity{}, + Database: &decisionpb.Resource{ + Kind: types.KindDatabase, + Name: c.databaseDetails.databaseID, + }, + }) + if err != nil { + return trace.Wrap(err) + } + + if err := utils.WriteJSON(c.output, resp); err != nil { + return trace.Wrap(err, "failed to marshal result") + } + + return nil +} diff --git a/tool/tctl/common/decision/evaluate_db_command_test.go b/tool/tctl/common/decision/evaluate_db_command_test.go new file mode 100644 index 0000000000000..0e2e8e178f21b --- /dev/null +++ b/tool/tctl/common/decision/evaluate_db_command_test.go @@ -0,0 +1,87 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package decision_test + +import ( + "bytes" + "context" + "testing" + + "github.com/alecthomas/kingpin/v2" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport" + decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/tool/tctl/common/decision" +) + +func TestEvaluateDB(t *testing.T) { + tests := []struct { + name string + response *decisionpb.EvaluateDatabaseAccessResponse + }{ + { + name: "denied", + response: &decisionpb.EvaluateDatabaseAccessResponse{ + Result: &decisionpb.EvaluateDatabaseAccessResponse_Denial{ + Denial: &decisionpb.DatabaseAccessDenial{ + Metadata: &decisionpb.DenialMetadata{ + PdpVersion: teleport.Version, + UserMessage: "denial", + }, + }, + }, + }, + }, + { + name: "permitted", + response: &decisionpb.EvaluateDatabaseAccessResponse{ + Result: &decisionpb.EvaluateDatabaseAccessResponse_Permit{ + Permit: &decisionpb.DatabaseAccessPermit{ + Metadata: &decisionpb.PermitMetadata{ + PdpVersion: teleport.Version, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := decision.EvaluateDatabaseCommand{} + + var output bytes.Buffer + cmd.Initialize(kingpin.New("tctl", "test").Command("decision", ""), &output) + + svc := fakeDecisionService{ + decisionClient: fakeDecisionServiceClient{ + databaseResponse: test.response, + }, + } + + err := cmd.Run(context.Background(), svc) + require.NoError(t, err, "evaluating database access failed") + + var expected bytes.Buffer + utils.WriteJSON(&expected, test.response) + require.NoError(t, err, "marshaling expected output failed") + require.Equal(t, output.String(), expected.String(), "output did not match") + }) + } +} diff --git a/tool/tctl/common/decision/evaluate_ssh_command.go b/tool/tctl/common/decision/evaluate_ssh_command.go new file mode 100644 index 0000000000000..534cef252a90f --- /dev/null +++ b/tool/tctl/common/decision/evaluate_ssh_command.go @@ -0,0 +1,80 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +package decision + +import ( + "context" + "io" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport" + decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/utils" +) + +// EvaluateSSHCommand is a command to evaluate +// SSH access via the Teleport Decision Service. +type EvaluateSSHCommand struct { + output io.Writer + + sshDetails sshDetails + command *kingpin.CmdClause +} + +type sshDetails struct { + serverID string + username string + login string +} + +// Initialize sets up the "tctl decision evaluate ssh" command. +func (c *EvaluateSSHCommand) Initialize(cmd *kingpin.CmdClause, output io.Writer) { + c.output = output + c.command = cmd.Command("evaluate-ssh-access", "Evaluate SSH access for a user.").Hidden() + c.command.Flag("username", "The username to evaluate access for.").StringVar(&c.sshDetails.username) + c.command.Flag("login", "The os login to evaluate access for.").StringVar(&c.sshDetails.login) + c.command.Flag("server-id", "The host id of the target server.").StringVar(&c.sshDetails.serverID) +} + +// FullCommand returns the fully qualified name of +// the subcommand, i.e. tctl decision evaluate ssh. +func (c *EvaluateSSHCommand) FullCommand() string { + return c.command.FullCommand() +} + +// Run executes the subcommand. +func (c *EvaluateSSHCommand) Run(ctx context.Context, clt Client) error { + resp, err := clt.DecisionClient().EvaluateSSHAccess(ctx, &decisionpb.EvaluateSSHAccessRequest{ + Metadata: &decisionpb.RequestMetadata{PepVersionHint: teleport.Version}, + SshIdentity: &decisionpb.SSHIdentity{}, + Node: &decisionpb.Resource{ + Kind: types.KindNode, + Name: c.sshDetails.serverID, + }, + }) + if err != nil { + return trace.Wrap(err) + } + + if err := utils.WriteJSON(c.output, resp); err != nil { + return trace.Wrap(err, "failed to marshal result") + } + + return nil +} diff --git a/tool/tctl/common/decision/evaluate_ssh_command_test.go b/tool/tctl/common/decision/evaluate_ssh_command_test.go new file mode 100644 index 0000000000000..d3709e73d4dbc --- /dev/null +++ b/tool/tctl/common/decision/evaluate_ssh_command_test.go @@ -0,0 +1,94 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package decision_test + +import ( + "bytes" + "context" + "testing" + + "github.com/alecthomas/kingpin/v2" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/gravitational/teleport" + decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/tool/tctl/common/decision" +) + +func TestEvaluateSSH(t *testing.T) { + tests := []struct { + name string + response *decisionpb.EvaluateSSHAccessResponse + }{ + { + name: "denied", + response: &decisionpb.EvaluateSSHAccessResponse{ + Decision: &decisionpb.EvaluateSSHAccessResponse_Denial{ + Denial: &decisionpb.SSHAccessDenial{ + Metadata: &decisionpb.DenialMetadata{ + PdpVersion: teleport.Version, + UserMessage: "denial", + }, + }, + }, + }, + }, + { + name: "permitted", + response: &decisionpb.EvaluateSSHAccessResponse{ + Decision: &decisionpb.EvaluateSSHAccessResponse_Permit{ + Permit: &decisionpb.SSHAccessPermit{ + Metadata: &decisionpb.PermitMetadata{ + PdpVersion: teleport.Version, + }, + Logins: []string{"llama", "beast"}, + ForwardAgent: true, + MaxSessionTtl: durationpb.New(10), + PortForwarding: false, + ClientIdleTimeout: 1000, + DisconnectExpiredCert: true, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cmd := decision.EvaluateSSHCommand{} + + var output bytes.Buffer + cmd.Initialize(kingpin.New("tctl", "test").Command("decision", ""), &output) + + svc := fakeDecisionService{ + decisionClient: fakeDecisionServiceClient{ + sshResponse: test.response, + }, + } + + err := cmd.Run(context.Background(), svc) + require.NoError(t, err, "evaluating SSH access failed") + + var expected bytes.Buffer + utils.WriteJSON(&expected, test.response) + require.NoError(t, err, "marshaling expected output failed") + require.Equal(t, output.String(), expected.String(), "output did not match") + }) + } +}