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..d5edba763d830
--- /dev/null
+++ b/tool/tctl/common/decision/command.go
@@ -0,0 +1,54 @@
+// 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"
+
+ "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
+
+ evaluateCommand EvaluateCommand
+}
+
+// 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.evaluateCommand.Initialize(cmd, c.Output)
+}
+
+// TryRun attempts to run subcommands.
+func (c *Command) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (bool, error) {
+ match, err := c.evaluateCommand.TryRun(ctx, cmd, clientFunc)
+ return match, trace.Wrap(err)
+}
diff --git a/tool/tctl/common/decision/command_test.go b/tool/tctl/common/decision/command_test.go
new file mode 100644
index 0000000000000..08fe8e3ed241c
--- /dev/null
+++ b/tool/tctl/common/decision/command_test.go
@@ -0,0 +1,59 @@
+// 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 (
+ "context"
+
+ "google.golang.org/grpc"
+
+ decisionpb "github.com/gravitational/teleport/api/gen/proto/go/teleport/decision/v1alpha1"
+ "github.com/gravitational/teleport/lib/auth/authclient"
+)
+
+type fakeDecisionService struct {
+ authclient.ClientI
+
+ 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 clientFunc(f fakeDecisionService) func(ctx context.Context) (client authclient.ClientI, close func(context.Context), err error) {
+ return func(ctx context.Context) (client authclient.ClientI, close func(context.Context), err error) {
+ return f, func(ctx context.Context) {}, nil
+ }
+}
diff --git a/tool/tctl/common/decision/evaluate_command.go b/tool/tctl/common/decision/evaluate_command.go
new file mode 100644
index 0000000000000..7eaeb9fe62801
--- /dev/null
+++ b/tool/tctl/common/decision/evaluate_command.go
@@ -0,0 +1,63 @@
+// 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/lib/auth/authclient"
+ "github.com/gravitational/teleport/tool/tctl/common/client"
+)
+
+// EvaluateCommand is a group of commands to evaluate access
+// via the Teleport Decision Service.
+type EvaluateCommand struct {
+ sshCommand EvaluateSSHCommand
+ dbCommand EvaluateDatabaseCommand
+}
+
+// Initialize sets up the "tctl decision evaluate" command.
+func (c *EvaluateCommand) Initialize(cmd *kingpin.CmdClause, output io.Writer) {
+ evaluateCommand := cmd.Command("evaluate", "Evaluate access for a user.")
+ c.sshCommand.Initialize(evaluateCommand, output)
+ c.dbCommand.Initialize(evaluateCommand, output)
+}
+
+// TryRun attempts to run subcommands.
+func (c *EvaluateCommand) TryRun(ctx context.Context, cmd string, clientFunc client.InitFunc) (bool, error) {
+ var run func(context.Context, authclient.ClientI) error
+ switch cmd {
+ case c.sshCommand.FullCommand():
+ run = c.sshCommand.Run
+ case c.dbCommand.FullCommand():
+ run = c.dbCommand.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/evaluate_db_command.go b/tool/tctl/common/decision/evaluate_db_command.go
new file mode 100644
index 0000000000000..56f35c69de8d1
--- /dev/null
+++ b/tool/tctl/common/decision/evaluate_db_command.go
@@ -0,0 +1,76 @@
+// 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/auth/authclient"
+ "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("db", "Evaluate database access for a user.")
+ 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 authclient.ClientI) 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..5bd47a948633e
--- /dev/null
+++ b/tool/tctl/common/decision/evaluate_db_command_test.go
@@ -0,0 +1,90 @@
+// 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) {
+ var output bytes.Buffer
+ cmd := decision.Command{
+ Output: &output,
+ }
+
+ cmd.Initialize(kingpin.New("decision", "test"), nil, nil)
+
+ svc := fakeDecisionService{
+ decisionClient: fakeDecisionServiceClient{
+ databaseResponse: test.response,
+ },
+ }
+
+ match, err := cmd.TryRun(context.Background(), "decision evaluate db", clientFunc(svc))
+ require.True(t, match, "evaluate db subcommand was not found")
+ 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..547800d173a27
--- /dev/null
+++ b/tool/tctl/common/decision/evaluate_ssh_command.go
@@ -0,0 +1,81 @@
+// 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/auth/authclient"
+ "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("ssh", "Evaluate SSH access for a user.")
+ 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 authclient.ClientI) 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..1780f6d9dcc9e
--- /dev/null
+++ b/tool/tctl/common/decision/evaluate_ssh_command_test.go
@@ -0,0 +1,97 @@
+// 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) {
+ var output bytes.Buffer
+ cmd := decision.Command{
+ Output: &output,
+ }
+
+ cmd.Initialize(kingpin.New("tctl", "test"), nil, nil)
+
+ svc := fakeDecisionService{
+ decisionClient: fakeDecisionServiceClient{
+ sshResponse: test.response,
+ },
+ }
+
+ match, err := cmd.TryRun(context.Background(), "decision evaluate ssh", clientFunc(svc))
+ require.True(t, match, "evaluate ssh subcommand was not found")
+ 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")
+ })
+ }
+}