diff --git a/tool/tctl/common/accessgraph/command.go b/tool/tctl/common/accessgraph/command.go
new file mode 100644
index 00000000000..c7d7a2a62e1
--- /dev/null
+++ b/tool/tctl/common/accessgraph/command.go
@@ -0,0 +1,103 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 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 accessgraph
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/lib/service/servicecfg"
+ "github.com/gravitational/teleport/lib/utils"
+ commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
+ tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
+)
+
+// AccessGraphCommand implements experimental Access Graph commands.
+//
+// The user-facing surface is currently a single hidden diagnostic
+// (`tctl accessgraph credentials`) that validates / re-issues the
+// Access Graph credential without making any AG calls. It exists
+// primarily so the credential plumbing has a reachable entry point
+// ahead of the real `access`/`detections`/etc. subcommands.
+type AccessGraphCommand struct {
+ ccf *tctlcfg.GlobalCLIFlags
+ config *servicecfg.Config
+ stdout io.Writer
+
+ // accessgraph is the parent command grouping AG subcommands.
+ // TODO(ghassan): remove when the real AG subcommands are implemented
+ accessgraph *kingpin.CmdClause
+ // credentials is a hidden diagnostic that exercises the credential
+ // resolve/re-issue path.
+ // TODO(ghassan): remove when the real AG subcommands are implemented
+ credentials *kingpin.CmdClause
+}
+
+// Initialize allows AccessGraphCommand to plug itself into the CLI parser.
+func (c *AccessGraphCommand) Initialize(app *kingpin.Application, cliFlags *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) {
+ c.ccf = cliFlags
+ c.config = config
+ if c.stdout == nil {
+ c.stdout = os.Stdout
+ }
+
+ c.accessgraph = app.Command("accessgraph", "Manage Access Graph (experimental).").Hidden()
+ c.credentials = c.accessgraph.Command("credentials", "Validate and re-issue the Access Graph credential.").Hidden()
+}
+
+// TryRun takes the CLI command as an argument and executes it.
+func (c *AccessGraphCommand) TryRun(ctx context.Context, cmd string, clientFunc commonclient.InitFunc) (match bool, err error) {
+ // Access Graph commands bypass the normal tctl auth flow (ApplyConfig), so
+ // the logger is never upgraded from its default Warn level. Do it here.
+ if c.ccf.Debug {
+ utils.InitLogger(utils.LoggingForCLI, slog.LevelDebug)
+ }
+
+ var commandFunc func(context.Context, commonclient.InitFunc) error
+ switch cmd {
+ case c.credentials.FullCommand():
+ commandFunc = c.runCredentialsCheck
+ default:
+ return false, nil
+ }
+ return true, trace.Wrap(commandFunc(ctx, clientFunc))
+}
+
+// runCredentialsCheck resolves the Access Graph credential and re-issues
+// it if missing or stale, then reports the outcome. It does not contact
+// the Access Graph service itself — only the auth path is exercised.
+//
+// TODO(ghassan): remove when the real AG subcommands are implemented
+func (c *AccessGraphCommand) runCredentialsCheck(ctx context.Context, clientFunc commonclient.InitFunc) error {
+ creds, err := c.loadAccessGraphCredentials(ctx)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ if err := ensureAccessGraphCert(ctx, creds, clientFunc); err != nil {
+ return trace.Wrap(err)
+ }
+ fmt.Fprintf(c.stdout, "Access Graph credential is valid (proxy: %s).\n", creds.proxyAddr)
+ return nil
+}
diff --git a/tool/tctl/common/accessgraph/credentials.go b/tool/tctl/common/accessgraph/credentials.go
new file mode 100644
index 00000000000..2a6fb9994cd
--- /dev/null
+++ b/tool/tctl/common/accessgraph/credentials.go
@@ -0,0 +1,358 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 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 accessgraph
+
+import (
+ "bytes"
+ "context"
+ "log/slog"
+ "time"
+
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/api/client/proto"
+ "github.com/gravitational/teleport/api/utils/keys"
+ "github.com/gravitational/teleport/entitlements"
+ "github.com/gravitational/teleport/lib/auth/authclient"
+ "github.com/gravitational/teleport/lib/client"
+ commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
+ tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
+)
+
+// accessGraphSetupDocURL is surfaced in the user-visible error when
+// Access Graph is licensed but not yet configured on the cluster.
+const accessGraphSetupDocURL = "https://goteleport.com/docs/identity-security/"
+
+// unlicensedAccessGraphMessage is returned when the cluster lacks the
+// Identity Security license that gates Access Graph.
+const unlicensedAccessGraphMessage = "this Teleport cluster is not licensed for Identity Security, " +
+ "which is required to use Access Graph. Contact your Teleport " +
+ "account team to enable it."
+
+// unconfiguredAccessGraphMessage is returned when the cluster is
+// licensed for Access Graph but the operator has not wired up the
+// `access_graph` block in the auth service config.
+const unconfiguredAccessGraphMessage = "Access Graph is licensed on this cluster but not configured. " +
+ "On self-hosted clusters, add an `access_graph` section to the " +
+ "auth_service config in teleport.yaml and restart auth; on " +
+ "Teleport Cloud, enable Access Graph from the cluster admin " +
+ "settings. See %s for setup instructions."
+
+// accessGraphMinPersistTTL is a preventative cert-lifetime floor for
+// disk persistence — `IsMFARequired` (used by `tsh login` `onAppLogin`)
+// isn't available for AG. 5m exceeds the 1m single-use MFA clamp.
+const accessGraphMinPersistTTL = 5 * time.Minute
+
+// accessGraphCertExpiryBuffer is the pre-expiry guard for in-flight AG calls
+// (1m mirrors the issuance NotBefore clock-skew backdate, plus 1m of
+// operational margin).
+const accessGraphCertExpiryBuffer = 2 * time.Minute
+
+// accessGraphCredentials bundles AG client state. The resolver only
+// sets `proxyAddr` for the `tsh login` flow; identity-file mode leaves
+// it empty and `ensureAccessGraphCert` fills it from `Ping`.
+type accessGraphCredentials struct {
+ proxyAddr string
+ clientStore *client.Store
+ keyRing *client.KeyRing
+}
+
+// authHostNotSupportedMessage explains why `tctl ` refuses
+// to run directly on the auth host (no user to bind the issued cert to)
+// and points the operator at the supported flows.
+const authHostNotSupportedMessage = `Access Graph credentials cannot be issued directly on the auth host: this command is being run without a tsh profile or identity file, so there is no user to bind the issued certificate to.
+
+The recommended flow is to run ` + "`tsh login`" + ` from a workstation, then re-run this command from that workstation — it will pick up the resulting profile and populate the Access Graph cert in that user's keyring.
+
+If you must run ` + "`tsh login`" + ` on this auth host directly, be aware that it will create a ` + "`~/.tsh`" + ` profile directory for the invoking OS user (if one does not already exist) and write credentials into it. That directory should be cleaned up afterwards. To avoid writing to ` + "`~/.tsh`" + ` at all, either:
+ - set ` + "`TELEPORT_HOME`" + ` to an isolated path before running ` + "`tsh login`" + ` and this command (the directory under that path will still need to be cleaned up afterwards), or
+ - pass a pre-issued identity file via ` + "`tctl -i --auth-server `" + ` and skip ` + "`tsh login`" + ` entirely.`
+
+// loadAccessGraphCredentials resolves AG credentials from the active
+// profile (or an identity file via `tctl -i`). Running directly on the
+// auth host without a client store is not supported;
+// see `authHostNotSupportedMessage` for the rationale.
+func (c *AccessGraphCommand) loadAccessGraphCredentials(ctx context.Context) (*accessGraphCredentials, error) {
+ resolved, err := tctlcfg.ApplyConfig(c.ccf, c.config)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if resolved.ClientStore == nil {
+ return nil, trace.BadParameter("%s", authHostNotSupportedMessage)
+ }
+ return resolveAccessGraphCredentials(ctx, c.ccf, resolved)
+}
+
+// resolveAccessGraphCredentials builds an `accessGraphCredentials` from
+// an already-resolved tctl config (profile or identity file).
+func resolveAccessGraphCredentials(ctx context.Context, ccf *tctlcfg.GlobalCLIFlags, resolved *tctlcfg.ResolvedConfig) (*accessGraphCredentials, error) {
+ if ccf == nil || resolved == nil || resolved.ClientStore == nil || resolved.Profile == nil {
+ return nil, trace.BadParameter("missing client store or profile")
+ }
+ profile := resolved.Profile
+ if profile.ProxyURL.Host == "" {
+ return nil, trace.NotFound("could not find the proxy public address for the requested profile")
+ }
+
+ idx := client.KeyRingIndex{
+ ProxyHost: profile.Name,
+ ClusterName: profile.Cluster,
+ Username: profile.Username,
+ }
+ keyRing, err := resolved.ClientStore.GetKeyRing(idx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ // Identity-file mode: `profile.ProxyURL.Host` reflects
+ // `--auth-server`, which may be an auth gRPC address. Leave
+ // `proxyAddr` empty so `ensureAccessGraphCert` resolves it
+ // from `Ping`.
+ proxyAddr := ""
+ if ccf.IdentityFilePath == "" {
+ proxyAddr = profile.ProxyURL.Host
+ }
+
+ slog.DebugContext(ctx, "Loaded Access Graph credentials",
+ "proxy_addr", proxyAddr,
+ "profile_name", profile.Name,
+ "cluster", profile.Cluster,
+ "username", profile.Username,
+ "identity_file", ccf.IdentityFilePath != "",
+ "has_access_graph_cert", len(keyRing.AccessGraphTLSCert) > 0,
+ )
+
+ return &accessGraphCredentials{
+ proxyAddr: proxyAddr,
+ clientStore: resolved.ClientStore,
+ keyRing: keyRing,
+ }, nil
+}
+
+// ensureAccessGraphCert reuses a valid cached cert or re-issues via
+// the auth client; on re-issue, an empty `creds.proxyAddr` is filled
+// from `Ping`.
+func ensureAccessGraphCert(ctx context.Context, creds *accessGraphCredentials, clientFunc commonclient.InitFunc) error {
+ if creds == nil || creds.keyRing == nil {
+ return trace.BadParameter("missing access graph credentials")
+ }
+
+ // Fast path: previously-issued cert is still on an FS-backed keyring
+ // and validates. Only the `tsh login` flow exercises this; auth-host
+ // and identity-file modes don't carry AG certs and always re-issue.
+ if creds.proxyAddr != "" && validateAccessGraphCert(ctx, creds.keyRing) {
+ slog.DebugContext(ctx, "Reusing existing Access Graph certificate from keyring on disk",
+ "proxy_addr", creds.proxyAddr,
+ "username", creds.keyRing.Username,
+ )
+ return nil
+ }
+
+ slog.DebugContext(ctx, "Re-issuing Access Graph certificate",
+ "proxy_addr", creds.proxyAddr,
+ "username", creds.keyRing.Username,
+ )
+
+ authClient, closeFn, err := clientFunc(ctx)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ defer closeFn(ctx)
+
+ ping, err := authClient.Ping(ctx)
+ if err != nil {
+ return trace.Wrap(err, "pinging cluster to check Access Graph support")
+ }
+ if err := checkAccessGraphSupported(ctx, ping); err != nil {
+ return trace.Wrap(err)
+ }
+
+ // Backfill `proxyAddr` only when the resolver left it empty so
+ // `tsh login` keeps `profile.ProxyURL.Host` even on clusters
+ // without `proxy_service.public_addr`.
+ if creds.proxyAddr == "" {
+ creds.proxyAddr = ping.GetProxyPublicAddr()
+ if creds.proxyAddr == "" {
+ return trace.NotFound("auth server did not advertise a proxy public address; set proxy_service.public_addr")
+ }
+ slog.DebugContext(ctx, "Resolved Access Graph proxy address from auth ping",
+ "proxy_addr", creds.proxyAddr,
+ )
+ }
+ return trace.Wrap(issueAndStoreAccessGraphCert(ctx, creds, authClient))
+}
+
+// checkAccessGraphSupported gates on the `Policy` entitlement.
+// Endpoint reachability is checked at AG call time, not here.
+func checkAccessGraphSupported(ctx context.Context, ping proto.PingResponse) error {
+ features := ping.GetServerFeatures()
+
+ // This gate routes the "not licensed" error message and accepts the legacy
+ // `Policy` submessage from older clusters that predate the entitlements map.
+ //
+ // TODO(ghassan): when #66571's follow-ups split Identity Security out of Policy,
+ // gate on the more specific entitlement here and keep Policy as the legacy fallback.
+ policy := features.GetEntitlements()[string(entitlements.Policy)]
+ licensed := policy.GetEnabled() || features.GetPolicy().GetEnabled()
+ if !licensed {
+ return trace.AccessDenied(unlicensedAccessGraphMessage)
+ }
+
+ if !features.GetAccessGraph() {
+ return trace.AccessDenied(unconfiguredAccessGraphMessage, accessGraphSetupDocURL)
+ }
+
+ slog.DebugContext(ctx, "Access Graph is available on this cluster",
+ "licensed", licensed,
+ "access_graph_flag", features.GetAccessGraph(),
+ )
+ return nil
+}
+
+// issueAndStoreAccessGraphCert mints a new Access Graph cert and, when
+// shouldPersistAccessGraphCert allows, persists the updated keyring.
+func issueAndStoreAccessGraphCert(ctx context.Context, creds *accessGraphCredentials, authClient authclient.ClientI) error {
+ if err := issueAccessGraphCert(ctx, creds.keyRing, authClient); err != nil {
+ return trace.Wrap(err)
+ }
+
+ if !shouldPersistAccessGraphCert(ctx, creds) {
+ slog.DebugContext(ctx, "Skipping Access Graph cert persistence",
+ "has_client_store", creds.clientStore != nil,
+ "proxy_addr", creds.proxyAddr,
+ "username", creds.keyRing.Username,
+ )
+ return nil
+ }
+
+ if err := creds.clientStore.AddKeyRing(creds.keyRing); err != nil {
+ return trace.Wrap(err)
+ }
+
+ slog.DebugContext(ctx, "Stored Access Graph certificate in keyring",
+ "proxy_addr", creds.proxyAddr,
+ "cluster", creds.keyRing.ClusterName,
+ "username", creds.keyRing.Username,
+ )
+
+ return nil
+}
+
+// shouldPersistAccessGraphCert gates `AddKeyRing`; identity-file uses
+// `MemClientStore` so this is a no-op for disk regardless.
+func shouldPersistAccessGraphCert(ctx context.Context, creds *accessGraphCredentials) bool {
+ if creds.clientStore == nil {
+ return false
+ }
+ if len(creds.keyRing.AccessGraphTLSCert) == 0 {
+ return false
+ }
+ expires, err := creds.keyRing.AccessGraphTLSCertValidBefore()
+ if err != nil {
+ slog.DebugContext(ctx, "Failed to read Access Graph certificate expiration", "error", err)
+ return false
+ }
+ return expires.After(time.Now().Add(accessGraphMinPersistTTL))
+}
+
+// validateAccessGraphCert reports whether the keyring's Access Graph
+// cert is present, unexpired, and bound to the keyring's TLS private key.
+func validateAccessGraphCert(ctx context.Context, keyRing *client.KeyRing) bool {
+ if len(keyRing.AccessGraphTLSCert) == 0 {
+ slog.DebugContext(ctx, "Access Graph certificate not present in keyring")
+ return false
+ }
+ if !validateAccessGraphCertExpiration(ctx, keyRing) {
+ return false
+ }
+ return validateAccessGraphPrivateKey(ctx, keyRing)
+}
+
+// validateAccessGraphCertExpiration reports whether the cert is valid
+// for at least accessGraphCertExpiryBuffer past now.
+func validateAccessGraphCertExpiration(ctx context.Context, keyRing *client.KeyRing) bool {
+ expires, err := keyRing.AccessGraphTLSCertValidBefore()
+ if err != nil {
+ slog.DebugContext(ctx, "Failed to read Access Graph certificate expiration", "error", err)
+ return false
+ }
+ if !expires.After(time.Now().Add(accessGraphCertExpiryBuffer)) {
+ slog.DebugContext(ctx, "Access Graph certificate is expired or below buffer", "expires", expires)
+ return false
+ }
+ return true
+}
+
+// validateAccessGraphPrivateKey checks that the cert's subject public key
+// matches the keyring's TLS private key.
+func validateAccessGraphPrivateKey(ctx context.Context, keyRing *client.KeyRing) bool {
+ cert, err := keyRing.AccessGraphTLSCertificate()
+ if err != nil {
+ slog.DebugContext(ctx, "Failed to parse Access Graph certificate", "error", err)
+ return false
+ }
+
+ certPub, err := keys.MarshalPublicKey(cert.PublicKey)
+ if err != nil {
+ slog.DebugContext(ctx, "Failed to marshal Access Graph certificate public key", "error", err)
+ return false
+ }
+ keyPub, err := keyRing.TLSPrivateKey.MarshalTLSPublicKey()
+ if err != nil {
+ slog.DebugContext(ctx, "Failed to marshal keyring TLS public key", "error", err)
+ return false
+ }
+ if !bytes.Equal(certPub, keyPub) {
+ slog.DebugContext(ctx, "Access Graph certificate public key does not match the keyring's TLS private key")
+ return false
+ }
+ return true
+}
+
+// issueAccessGraphCert mints a new Access Graph TLS cert and sets it on
+// keyRing in memory; the caller persists it (when appropriate).
+// The cert's NotAfter is bound to the keyring's Teleport TLS cert.
+func issueAccessGraphCert(ctx context.Context, keyRing *client.KeyRing, rootAuthClient authclient.ClientI) error {
+ tlsPublicKey, err := keys.MarshalPublicKey(keyRing.TLSPrivateKey.Public())
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ expires, err := keyRing.TeleportTLSCertValidBefore()
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ certs, err := rootAuthClient.GenerateUserCerts(ctx, proto.UserCertsRequest{
+ TLSPublicKey: tlsPublicKey,
+ Username: keyRing.Username,
+ Expires: expires,
+ Usage: proto.UserCertsRequest_AccessGraphAPI,
+ })
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ keyRing.AccessGraphTLSCert = certs.TLS
+ slog.DebugContext(ctx, "Issued new Access Graph certificate",
+ "username", keyRing.Username,
+ "expires", expires,
+ )
+ return nil
+}
diff --git a/tool/tctl/common/accessgraph/credentials_test.go b/tool/tctl/common/accessgraph/credentials_test.go
new file mode 100644
index 00000000000..3f71ff9e663
--- /dev/null
+++ b/tool/tctl/common/accessgraph/credentials_test.go
@@ -0,0 +1,751 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 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 accessgraph
+
+import (
+ "context"
+ "crypto"
+ "crypto/x509/pkix"
+ "errors"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/gravitational/trace"
+ "github.com/jonboulle/clockwork"
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/client/proto"
+ "github.com/gravitational/teleport/api/utils/keys"
+ "github.com/gravitational/teleport/entitlements"
+ "github.com/gravitational/teleport/lib/auth/authclient"
+ "github.com/gravitational/teleport/lib/client"
+ "github.com/gravitational/teleport/lib/cryptosuites"
+ "github.com/gravitational/teleport/lib/tlsca"
+ commonclient "github.com/gravitational/teleport/tool/tctl/common/client"
+ tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
+)
+
+// testCA bundles a self-signed CA and a clock; it mints Access Graph TLS
+// certs against an arbitrary public key for the validation tests below.
+type testCA struct {
+ ca *tlsca.CertAuthority
+ clock clockwork.Clock
+}
+
+func newTestCA(t *testing.T) *testCA {
+ t.Helper()
+ caKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
+ require.NoError(t, err)
+ caCertPEM, err := tlsca.GenerateSelfSignedCAWithSigner(
+ caKey,
+ pkix.Name{CommonName: "test-ca", Organization: []string{"test"}},
+ nil,
+ time.Hour,
+ )
+ require.NoError(t, err)
+ ca, err := tlsca.FromCertAndSigner(caCertPEM, caKey)
+ require.NoError(t, err)
+ return &testCA{ca: ca, clock: clockwork.NewRealClock()}
+}
+
+func (c *testCA) signAccessGraphCert(t *testing.T, pub crypto.PublicKey, ttl time.Duration) []byte {
+ t.Helper()
+ identity := tlsca.Identity{Username: "alice", Groups: []string{"access"}}
+ subject, err := identity.Subject()
+ require.NoError(t, err)
+ cert, err := c.ca.GenerateCertificate(tlsca.CertificateRequest{
+ Clock: c.clock,
+ PublicKey: pub,
+ Subject: subject,
+ NotAfter: c.clock.Now().Add(ttl),
+ })
+ require.NoError(t, err)
+ return cert
+}
+
+// newTestKeyRing returns a KeyRing populated with a fresh TLS private key —
+// enough for the validation helpers, which only inspect AccessGraphTLSCert
+// and TLSPrivateKey.
+func newTestKeyRing(t *testing.T) *client.KeyRing {
+ t.Helper()
+ tlsKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
+ require.NoError(t, err)
+ tlsPriv, err := keys.NewPrivateKey(tlsKey)
+ require.NoError(t, err)
+ return &client.KeyRing{
+ KeyRingIndex: client.KeyRingIndex{Username: "alice", ClusterName: "test", ProxyHost: "proxy"},
+ TLSPrivateKey: tlsPriv,
+ }
+}
+
+// withTeleportTLSCert attaches a Teleport TLS cert to the keyring, signed by
+// the test CA — required for issueAccessGraphCert, which derives the
+// requested NotAfter from the keyring's existing Teleport cert.
+func (c *testCA) withTeleportTLSCert(t *testing.T, kr *client.KeyRing, ttl time.Duration) *client.KeyRing {
+ t.Helper()
+ identity := tlsca.Identity{Username: kr.Username, Groups: []string{"access"}}
+ subject, err := identity.Subject()
+ require.NoError(t, err)
+ cert, err := c.ca.GenerateCertificate(tlsca.CertificateRequest{
+ Clock: c.clock,
+ PublicKey: kr.TLSPrivateKey.Public(),
+ Subject: subject,
+ NotAfter: c.clock.Now().Add(ttl),
+ })
+ require.NoError(t, err)
+ kr.TLSCert = cert
+ return kr
+}
+
+// mockAuthClient embeds *authclient.Client so unimplemented methods
+// compile away; we only override what the tests touch. Mirrors mockClient
+// in auth_command_test.go.
+type mockAuthClient struct {
+ *authclient.Client
+
+ generate func(ctx context.Context, req proto.UserCertsRequest) (*proto.Certs, error)
+ // ping defaults to a licensed-and-enabled response so the
+ // precondition gate is a no-op for tests that don't care.
+ ping func(ctx context.Context) (proto.PingResponse, error)
+
+ gotReq *proto.UserCertsRequest
+}
+
+func (m *mockAuthClient) GenerateUserCerts(ctx context.Context, req proto.UserCertsRequest) (*proto.Certs, error) {
+ m.gotReq = &req
+ if m.generate == nil {
+ return nil, errors.New("GenerateUserCerts not stubbed")
+ }
+ return m.generate(ctx, req)
+}
+
+func (m *mockAuthClient) Ping(ctx context.Context) (proto.PingResponse, error) {
+ if m.ping != nil {
+ return m.ping(ctx)
+ }
+ return pingResponseAccessGraphReady(), nil
+}
+
+// pingResponseAccessGraphReady is the canonical "licensed and enabled"
+// PingResponse used as the default mock response.
+func pingResponseAccessGraphReady() proto.PingResponse {
+ return proto.PingResponse{
+ ServerFeatures: &proto.Features{
+ AccessGraph: true,
+ Entitlements: map[string]*proto.EntitlementInfo{
+ string(entitlements.Policy): {Enabled: true},
+ },
+ },
+ }
+}
+
+func TestIssueAndStoreAccessGraphCert(t *testing.T) {
+ t.Parallel()
+ ca := newTestCA(t)
+
+ keyRing := ca.withTeleportTLSCert(t, newTestKeyRing(t), time.Hour)
+ store := client.NewMemClientStore()
+ creds := &accessGraphCredentials{
+ proxyAddr: "proxy.example.com:443",
+ clientStore: store,
+ keyRing: keyRing,
+ }
+
+ mock := &mockAuthClient{
+ generate: func(ctx context.Context, req proto.UserCertsRequest) (*proto.Certs, error) {
+ pub, err := keys.ParsePublicKey(req.TLSPublicKey)
+ require.NoError(t, err)
+ return &proto.Certs{
+ TLS: ca.signAccessGraphCert(t, pub, time.Hour),
+ }, nil
+ },
+ }
+
+ require.NoError(t, issueAndStoreAccessGraphCert(context.Background(), creds, mock))
+
+ // Auth client received the expected request.
+ require.NotNil(t, mock.gotReq)
+ require.Equal(t, "alice", mock.gotReq.Username)
+ require.Equal(t, proto.UserCertsRequest_AccessGraphAPI, mock.gotReq.Usage)
+
+ // The new cert is on the in-memory keyring and validates cleanly.
+ require.NotEmpty(t, keyRing.AccessGraphTLSCert)
+ require.True(t, validateAccessGraphCert(context.Background(), keyRing))
+
+ // And it was persisted in the client store under the resolved cluster name.
+ stored, err := store.GetKeyRing(client.KeyRingIndex{
+ ProxyHost: keyRing.ProxyHost,
+ Username: keyRing.Username,
+ ClusterName: keyRing.ClusterName,
+ })
+ require.NoError(t, err)
+ require.Equal(t, keyRing.AccessGraphTLSCert, stored.AccessGraphTLSCert)
+}
+
+func TestIssueAndStoreAccessGraphCert_SkipsOnNilStore(t *testing.T) {
+ t.Parallel()
+ ca := newTestCA(t)
+
+ keyRing := ca.withTeleportTLSCert(t, newTestKeyRing(t), time.Hour)
+ creds := &accessGraphCredentials{
+ proxyAddr: "proxy.example.com:443",
+ clientStore: nil,
+ keyRing: keyRing,
+ }
+
+ mock := &mockAuthClient{
+ generate: func(ctx context.Context, req proto.UserCertsRequest) (*proto.Certs, error) {
+ pub, err := keys.ParsePublicKey(req.TLSPublicKey)
+ require.NoError(t, err)
+ return &proto.Certs{TLS: ca.signAccessGraphCert(t, pub, time.Hour)}, nil
+ },
+ }
+
+ require.NoError(t, issueAndStoreAccessGraphCert(context.Background(), creds, mock))
+ require.NotEmpty(t, keyRing.AccessGraphTLSCert)
+}
+
+func TestIssueAndStoreAccessGraphCert_SkipsOnShortLivedCert(t *testing.T) {
+ t.Parallel()
+ ca := newTestCA(t)
+
+ keyRing := ca.withTeleportTLSCert(t, newTestKeyRing(t), time.Hour)
+ store := client.NewMemClientStore()
+ creds := &accessGraphCredentials{
+ proxyAddr: "proxy.example.com:443",
+ clientStore: store,
+ keyRing: keyRing,
+ }
+
+ mock := &mockAuthClient{
+ generate: func(ctx context.Context, req proto.UserCertsRequest) (*proto.Certs, error) {
+ pub, err := keys.ParsePublicKey(req.TLSPublicKey)
+ require.NoError(t, err)
+ return &proto.Certs{TLS: ca.signAccessGraphCert(t, pub, 30*time.Second)}, nil
+ },
+ }
+
+ require.NoError(t, issueAndStoreAccessGraphCert(context.Background(), creds, mock))
+ require.NotEmpty(t, keyRing.AccessGraphTLSCert)
+
+ stored, err := store.GetKeyRing(client.KeyRingIndex{
+ ProxyHost: keyRing.ProxyHost,
+ Username: keyRing.Username,
+ ClusterName: keyRing.ClusterName,
+ })
+ require.True(t, err != nil || len(stored.AccessGraphTLSCert) == 0,
+ "short-lived cert must not be persisted")
+}
+
+func TestEnsureAccessGraphCert_ReuseSkipsClientInit(t *testing.T) {
+ t.Parallel()
+ ca := newTestCA(t)
+
+ keyRing := newTestKeyRing(t)
+ keyRing.AccessGraphTLSCert = ca.signAccessGraphCert(t, keyRing.TLSPrivateKey.Public(), time.Hour)
+
+ creds := &accessGraphCredentials{
+ proxyAddr: "proxy.example.com:443",
+ clientStore: client.NewMemClientStore(),
+ keyRing: keyRing,
+ }
+
+ // If the existing cert validates, ensureAccessGraphCert must not
+ // initialize the (expensive) auth client. We track that by counting
+ // how many times the InitFunc closure is called.
+ var calls int
+ clientFunc := commonclient.InitFunc(func(ctx context.Context) (*authclient.Client, func(context.Context), error) {
+ calls++
+ return nil, func(context.Context) {}, errors.New("InitFunc should not be called when reusing a valid cert")
+ })
+
+ require.NoError(t, ensureAccessGraphCert(context.Background(), creds, clientFunc))
+ require.Zero(t, calls, "InitFunc must not be called when the existing cert is valid")
+}
+
+func TestResolveAccessGraphCredentials(t *testing.T) {
+ t.Parallel()
+
+ const (
+ proxyHost = "proxy.example.com"
+ username = "alice"
+ cluster = "test-cluster"
+ )
+
+ ca := newTestCA(t)
+ newResolved := func(t *testing.T) *tctlcfg.ResolvedConfig {
+ t.Helper()
+ store := client.NewMemClientStore()
+ kr := ca.withTeleportTLSCert(t, newTestKeyRing(t), time.Hour)
+ kr.KeyRingIndex = client.KeyRingIndex{
+ ProxyHost: proxyHost,
+ Username: username,
+ ClusterName: cluster,
+ }
+ require.NoError(t, store.AddKeyRing(kr))
+ return &tctlcfg.ResolvedConfig{
+ ClientStore: store,
+ Profile: &client.ProfileStatus{
+ Name: proxyHost,
+ Username: username,
+ Cluster: cluster,
+ ProxyURL: url.URL{Scheme: "https", Host: proxyHost + ":443"},
+ },
+ }
+ }
+
+ t.Run("happy path (profile)", func(t *testing.T) {
+ t.Parallel()
+ resolved := newResolved(t)
+
+ creds, err := resolveAccessGraphCredentials(context.Background(), &tctlcfg.GlobalCLIFlags{}, resolved)
+ require.NoError(t, err)
+ require.Equal(t, proxyHost+":443", creds.proxyAddr)
+ require.Same(t, resolved.ClientStore, creds.clientStore)
+ require.NotNil(t, creds.keyRing)
+ require.Equal(t, username, creds.keyRing.Username)
+ require.Equal(t, cluster, creds.keyRing.ClusterName)
+ })
+
+ t.Run("identity-file mode blanks proxyAddr", func(t *testing.T) {
+ t.Parallel()
+ // Identity-file blanks `proxyAddr`; covers
+ // `--auth-server=:3025` (auth gRPC, not proxy). See
+ // https://goteleport.com/docs/reference/cli/tctl/.
+ resolved := newResolved(t)
+
+ creds, err := resolveAccessGraphCredentials(context.Background(),
+ &tctlcfg.GlobalCLIFlags{IdentityFilePath: "/path/to/identity"}, resolved)
+ require.NoError(t, err)
+ require.Empty(t, creds.proxyAddr, "identity-file mode must not trust profile.ProxyURL.Host")
+ require.Same(t, resolved.ClientStore, creds.clientStore)
+ require.NotNil(t, creds.keyRing)
+ })
+
+ t.Run("missing proxy URL host", func(t *testing.T) {
+ t.Parallel()
+ resolved := newResolved(t)
+ resolved.Profile.ProxyURL = url.URL{}
+
+ _, err := resolveAccessGraphCredentials(context.Background(), &tctlcfg.GlobalCLIFlags{}, resolved)
+ require.True(t, trace.IsNotFound(err), "expected NotFound, got %v", err)
+ })
+
+ t.Run("keyring not in store", func(t *testing.T) {
+ t.Parallel()
+ resolved := &tctlcfg.ResolvedConfig{
+ ClientStore: client.NewMemClientStore(),
+ Profile: &client.ProfileStatus{
+ Name: proxyHost,
+ Username: username,
+ Cluster: cluster,
+ ProxyURL: url.URL{Scheme: "https", Host: proxyHost + ":443"},
+ },
+ }
+
+ _, err := resolveAccessGraphCredentials(context.Background(), &tctlcfg.GlobalCLIFlags{}, resolved)
+ require.True(t, trace.IsNotFound(err), "expected NotFound from GetKeyRing, got %v", err)
+ })
+
+ t.Run("uses profile.Name as ProxyHost", func(t *testing.T) {
+ t.Parallel()
+ // Verify the index uses `profile.Name`` (host-only, profile filename)
+ // rather than `profile.ProxyURL.Host` (which often includes a port).
+ store := client.NewMemClientStore()
+ kr := ca.withTeleportTLSCert(t, newTestKeyRing(t), time.Hour)
+ kr.KeyRingIndex = client.KeyRingIndex{
+ ProxyHost: proxyHost + ":443", // wrong: includes port
+ Username: username,
+ ClusterName: cluster,
+ }
+ require.NoError(t, store.AddKeyRing(kr))
+ resolved := &tctlcfg.ResolvedConfig{
+ ClientStore: store,
+ Profile: &client.ProfileStatus{
+ Name: proxyHost,
+ Username: username,
+ Cluster: cluster,
+ ProxyURL: url.URL{Scheme: "https", Host: proxyHost + ":443"},
+ },
+ }
+
+ _, err := resolveAccessGraphCredentials(context.Background(), &tctlcfg.GlobalCLIFlags{}, resolved)
+ require.Error(t, err, "lookup should miss when profile.Name disagrees with the stored ProxyHost")
+ })
+
+ t.Run("nil arguments", func(t *testing.T) {
+ t.Parallel()
+ _, err := resolveAccessGraphCredentials(context.Background(), nil, &tctlcfg.ResolvedConfig{})
+ require.True(t, trace.IsBadParameter(err))
+ _, err = resolveAccessGraphCredentials(context.Background(), &tctlcfg.GlobalCLIFlags{}, nil)
+ require.True(t, trace.IsBadParameter(err))
+ _, err = resolveAccessGraphCredentials(context.Background(), &tctlcfg.GlobalCLIFlags{}, &tctlcfg.ResolvedConfig{})
+ require.True(t, trace.IsBadParameter(err))
+ })
+}
+
+func TestEnsureAccessGraphCert_BadParameters(t *testing.T) {
+ t.Parallel()
+
+ clientFunc := commonclient.InitFunc(func(ctx context.Context) (*authclient.Client, func(context.Context), error) {
+ t.Fatalf("InitFunc must not be called when guard clauses fire")
+ return nil, nil, nil
+ })
+
+ require.True(t, trace.IsBadParameter(ensureAccessGraphCert(context.Background(), nil, clientFunc)))
+ require.True(t, trace.IsBadParameter(ensureAccessGraphCert(context.Background(), &accessGraphCredentials{}, clientFunc)))
+}
+
+func TestEnsureAccessGraphCert_ReissuePathInvokesClient(t *testing.T) {
+ t.Parallel()
+
+ // Empty AccessGraphTLSCert forces re-issue. We inject an InitFunc that
+ // returns an error so we can confirm the dispatch reached it without
+ // needing a working *authclient.Client.
+ creds := &accessGraphCredentials{
+ proxyAddr: "proxy.example.com:443",
+ clientStore: client.NewMemClientStore(),
+ keyRing: newTestKeyRing(t),
+ }
+
+ sentinel := errors.New("init invoked")
+ var calls int
+ clientFunc := commonclient.InitFunc(func(ctx context.Context) (*authclient.Client, func(context.Context), error) {
+ calls++
+ return nil, nil, sentinel
+ })
+
+ err := ensureAccessGraphCert(context.Background(), creds, clientFunc)
+ require.ErrorIs(t, err, sentinel)
+ require.Equal(t, 1, calls)
+}
+
+func TestValidateAccessGraphCert(t *testing.T) {
+ t.Parallel()
+ ca := newTestCA(t)
+
+ tests := []struct {
+ name string
+ setup func(t *testing.T) *client.KeyRing
+ want bool
+ }{
+ {
+ name: "no cert in keyring",
+ setup: func(t *testing.T) *client.KeyRing {
+ return newTestKeyRing(t)
+ },
+ want: false,
+ },
+ {
+ name: "malformed cert bytes",
+ setup: func(t *testing.T) *client.KeyRing {
+ kr := newTestKeyRing(t)
+ kr.AccessGraphTLSCert = []byte("not a certificate")
+ return kr
+ },
+ want: false,
+ },
+ {
+ name: "expired cert",
+ setup: func(t *testing.T) *client.KeyRing {
+ kr := newTestKeyRing(t)
+ kr.AccessGraphTLSCert = ca.signAccessGraphCert(t, kr.TLSPrivateKey.Public(), -time.Minute)
+ return kr
+ },
+ want: false,
+ },
+ {
+ name: "cert public key does not match keyring private key",
+ setup: func(t *testing.T) *client.KeyRing {
+ kr := newTestKeyRing(t)
+ otherKey, err := cryptosuites.GenerateKeyWithAlgorithm(cryptosuites.ECDSAP256)
+ require.NoError(t, err)
+ kr.AccessGraphTLSCert = ca.signAccessGraphCert(t, otherKey.Public(), time.Hour)
+ return kr
+ },
+ want: false,
+ },
+ {
+ name: "valid cert bound to keyring private key",
+ setup: func(t *testing.T) *client.KeyRing {
+ kr := newTestKeyRing(t)
+ kr.AccessGraphTLSCert = ca.signAccessGraphCert(t, kr.TLSPrivateKey.Public(), time.Hour)
+ return kr
+ },
+ want: true,
+ },
+ {
+ name: "cert lifetime below expiry buffer",
+ setup: func(t *testing.T) *client.KeyRing {
+ kr := newTestKeyRing(t)
+ kr.AccessGraphTLSCert = ca.signAccessGraphCert(t, kr.TLSPrivateKey.Public(), time.Minute)
+ return kr
+ },
+ want: false,
+ },
+ {
+ name: "cert lifetime just above expiry buffer",
+ setup: func(t *testing.T) *client.KeyRing {
+ kr := newTestKeyRing(t)
+ kr.AccessGraphTLSCert = ca.signAccessGraphCert(t, kr.TLSPrivateKey.Public(), 3*time.Minute)
+ return kr
+ },
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ kr := tt.setup(t)
+ require.Equal(t, tt.want, validateAccessGraphCert(context.Background(), kr))
+ })
+ }
+}
+
+func TestShouldPersistAccessGraphCert(t *testing.T) {
+ t.Parallel()
+ ca := newTestCA(t)
+
+ withCert := func(t *testing.T, ttl time.Duration) *client.KeyRing {
+ kr := newTestKeyRing(t)
+ kr.AccessGraphTLSCert = ca.signAccessGraphCert(t, kr.TLSPrivateKey.Public(), ttl)
+ return kr
+ }
+
+ tests := []struct {
+ name string
+ creds func(t *testing.T) *accessGraphCredentials
+ want bool
+ }{
+ {
+ name: "nil store",
+ creds: func(t *testing.T) *accessGraphCredentials {
+ return &accessGraphCredentials{keyRing: withCert(t, time.Hour)}
+ },
+ want: false,
+ },
+ {
+ name: "mfa-short cert (30s)",
+ creds: func(t *testing.T) *accessGraphCredentials {
+ return &accessGraphCredentials{
+ clientStore: client.NewMemClientStore(),
+ keyRing: withCert(t, 30*time.Second),
+ }
+ },
+ want: false,
+ },
+ {
+ name: "just inside threshold (4m59s)",
+ creds: func(t *testing.T) *accessGraphCredentials {
+ return &accessGraphCredentials{
+ clientStore: client.NewMemClientStore(),
+ keyRing: withCert(t, 4*time.Minute+59*time.Second),
+ }
+ },
+ want: false,
+ },
+ {
+ name: "just outside threshold (5m1s)",
+ creds: func(t *testing.T) *accessGraphCredentials {
+ return &accessGraphCredentials{
+ clientStore: client.NewMemClientStore(),
+ keyRing: withCert(t, 5*time.Minute+time.Second),
+ }
+ },
+ want: true,
+ },
+ {
+ name: "normal session (8h)",
+ creds: func(t *testing.T) *accessGraphCredentials {
+ return &accessGraphCredentials{
+ clientStore: client.NewMemClientStore(),
+ keyRing: withCert(t, 8*time.Hour),
+ }
+ },
+ want: true,
+ },
+ {
+ name: "already expired",
+ creds: func(t *testing.T) *accessGraphCredentials {
+ return &accessGraphCredentials{
+ clientStore: client.NewMemClientStore(),
+ keyRing: withCert(t, -time.Minute),
+ }
+ },
+ want: false,
+ },
+ {
+ name: "no cert in keyring",
+ creds: func(t *testing.T) *accessGraphCredentials {
+ return &accessGraphCredentials{
+ clientStore: client.NewMemClientStore(),
+ keyRing: newTestKeyRing(t),
+ }
+ },
+ want: false,
+ },
+ {
+ name: "malformed cert bytes",
+ creds: func(t *testing.T) *accessGraphCredentials {
+ kr := newTestKeyRing(t)
+ kr.AccessGraphTLSCert = []byte("not a certificate")
+ return &accessGraphCredentials{
+ clientStore: client.NewMemClientStore(),
+ keyRing: kr,
+ }
+ },
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := shouldPersistAccessGraphCert(context.Background(), tt.creds(t))
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
+
+// TestAccessGraphCertThresholdSplit asserts a cert between the expiry
+// buffer and the persist threshold is usable but not persisted.
+func TestAccessGraphCertThresholdSplit(t *testing.T) {
+ t.Parallel()
+ ca := newTestCA(t)
+
+ kr := newTestKeyRing(t)
+ // 3m: past expiry buffer (2m), below persist threshold (5m).
+ kr.AccessGraphTLSCert = ca.signAccessGraphCert(t, kr.TLSPrivateKey.Public(), 3*time.Minute)
+
+ require.True(t, validateAccessGraphCert(context.Background(), kr))
+
+ creds := &accessGraphCredentials{
+ clientStore: client.NewMemClientStore(),
+ keyRing: kr,
+ }
+ require.False(t, shouldPersistAccessGraphCert(context.Background(), creds))
+}
+
+// TestCheckAccessGraphSupported asserts the trace error category and that
+// each user-visible message names the missing piece and links to the right
+// docs.
+func TestCheckAccessGraphSupported(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ ping proto.PingResponse
+ wantErr func(error) bool
+ wantSubstr []string // every substring must appear in err.Error()
+ }{
+ {
+ name: "Policy entitlement enabled → no error",
+ ping: pingResponseAccessGraphReady(),
+ wantErr: func(err error) bool {
+ return err == nil
+ },
+ },
+ {
+ // Older clusters set only the legacy Policy submessage.
+ name: "legacy Policy submessage enabled → no error",
+ ping: proto.PingResponse{
+ ServerFeatures: &proto.Features{
+ AccessGraph: true,
+ Policy: &proto.PolicyFeature{Enabled: true},
+ },
+ },
+ wantErr: func(err error) bool { return err == nil },
+ },
+ {
+ name: "licensed but feature not enabled → AccessDenied, points at setup docs",
+ ping: proto.PingResponse{
+ ServerFeatures: &proto.Features{
+ AccessGraph: false,
+ Entitlements: map[string]*proto.EntitlementInfo{
+ string(entitlements.Policy): {Enabled: true},
+ },
+ },
+ },
+ wantErr: trace.IsAccessDenied,
+ wantSubstr: []string{
+ "not configured",
+ "access_graph",
+ "teleport.yaml",
+ accessGraphSetupDocURL,
+ },
+ },
+ {
+ name: "Policy entitlement explicitly disabled → AccessDenied",
+ ping: proto.PingResponse{
+ ServerFeatures: &proto.Features{
+ AccessGraph: true,
+ Entitlements: map[string]*proto.EntitlementInfo{
+ string(entitlements.Policy): {Enabled: false},
+ },
+ },
+ },
+ wantErr: trace.IsAccessDenied,
+ wantSubstr: []string{
+ "Identity Security",
+ },
+ },
+ {
+ name: "entitlements map missing entirely and no legacy Policy → AccessDenied",
+ ping: proto.PingResponse{
+ ServerFeatures: &proto.Features{AccessGraph: true},
+ },
+ wantErr: trace.IsAccessDenied,
+ wantSubstr: []string{
+ "Identity Security",
+ },
+ },
+ {
+ // Regression guard: the `AccessGraph` entitlement key is
+ // never populated by the modules code — only `Policy` is.
+ name: "AccessGraph-key entitlement on its own is NOT sufficient — Policy is the gate",
+ ping: proto.PingResponse{
+ ServerFeatures: &proto.Features{
+ AccessGraph: true,
+ Entitlements: map[string]*proto.EntitlementInfo{
+ string(entitlements.AccessGraph): {Enabled: true},
+ },
+ },
+ },
+ wantErr: trace.IsAccessDenied,
+ wantSubstr: []string{
+ "Identity Security",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ err := checkAccessGraphSupported(context.Background(), tt.ping)
+ require.True(t, tt.wantErr(err), "wantErr predicate failed for err=%v", err)
+ if err != nil {
+ for _, s := range tt.wantSubstr {
+ require.Contains(t, err.Error(), s)
+ }
+ }
+ })
+ }
+}
diff --git a/tool/tctl/common/client/auth.go b/tool/tctl/common/client/auth.go
index fd83af4c61f..fbfc8a95678 100644
--- a/tool/tctl/common/client/auth.go
+++ b/tool/tctl/common/client/auth.go
@@ -50,10 +50,11 @@ type InitFunc func(ctx context.Context) (client *authclient.Client, close func(c
// GetInitFunc wraps lazy loading auth init function for commands which requires the auth client.
func GetInitFunc(ccf tctlcfg.GlobalCLIFlags, cfg *servicecfg.Config) InitFunc {
return func(ctx context.Context) (*authclient.Client, func(context.Context), error) {
- clientConfig, err := tctlcfg.ApplyConfig(&ccf, cfg)
+ resolved, err := tctlcfg.ApplyConfig(&ccf, cfg)
if err != nil {
return nil, nil, trace.Wrap(err)
}
+ clientConfig := resolved.Auth
resolver, err := reversetunnelclient.CachingResolver(
ctx,
diff --git a/tool/tctl/common/cmds.go b/tool/tctl/common/cmds.go
index 6a6eedf11fd..8ec7ffd3b8f 100644
--- a/tool/tctl/common/cmds.go
+++ b/tool/tctl/common/cmds.go
@@ -19,6 +19,7 @@
package common
import (
+ "github.com/gravitational/teleport/tool/tctl/common/accessgraph"
"github.com/gravitational/teleport/tool/tctl/common/accessmonitoring"
"github.com/gravitational/teleport/tool/tctl/common/decision"
"github.com/gravitational/teleport/tool/tctl/common/discovery"
@@ -63,6 +64,7 @@ func Commands() []CLICommand {
&loginrule.Command{},
&IdPCommand{},
&accessmonitoring.Command{},
+ &accessgraph.AccessGraphCommand{},
&plugin.PluginsCommand{},
&NotificationCommand{},
&configure.SSOConfigureCommand{},
diff --git a/tool/tctl/common/config/global.go b/tool/tctl/common/config/global.go
index 8279003b096..e50f7c09a78 100644
--- a/tool/tctl/common/config/global.go
+++ b/tool/tctl/common/config/global.go
@@ -34,6 +34,7 @@ import (
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth/authclient"
"github.com/gravitational/teleport/lib/auth/storage"
+ "github.com/gravitational/teleport/lib/client"
"github.com/gravitational/teleport/lib/config"
"github.com/gravitational/teleport/lib/defaults"
"github.com/gravitational/teleport/lib/service/servicecfg"
@@ -60,12 +61,23 @@ type GlobalCLIFlags struct {
MFAMode string
}
+// ResolvedConfig is the full result of ApplyConfig: enough for tctl
+// commands to dial auth and (when applicable) reach into the caller's
+// client store. ClientStore and Profile are nil when tctl runs on the
+// auth host, where there is no tsh profile.
+type ResolvedConfig struct {
+ Auth *authclient.Config
+ ClientStore *client.Store
+ Profile *client.ProfileStatus
+}
+
// ApplyConfig takes configuration values from the config file and applies them
// to 'servicecfg.Config' object.
//
-// The returned authclient.Config has the credentials needed to dial the auth
-// server.
-func ApplyConfig(ccf *GlobalCLIFlags, cfg *servicecfg.Config) (*authclient.Config, error) {
+// The returned ResolvedConfig.Auth has the credentials needed to dial the
+// auth server. ClientStore and Profile are populated from ~/.tsh (or an
+// identity file) when present and nil for the local-auth-host path.
+func ApplyConfig(ccf *GlobalCLIFlags, cfg *servicecfg.Config) (*ResolvedConfig, error) {
ctx := context.TODO()
// --debug flag
if ccf.Debug {
@@ -131,9 +143,9 @@ func ApplyConfig(ccf *GlobalCLIFlags, cfg *servicecfg.Config) (*authclient.Confi
} else {
slog.DebugContext(ctx, "auth_service disabled in config file, loading auth config via extension")
}
- authConfig, err := LoadConfigFromProfile(ccf, cfg)
+ resolved, err := LoadFromProfileStore(ccf, cfg)
if err == nil {
- return authConfig, nil
+ return resolved, nil
}
if !trace.IsNotFound(err) {
return nil, trace.Wrap(err)
@@ -193,5 +205,5 @@ func ApplyConfig(ccf *GlobalCLIFlags, cfg *servicecfg.Config) (*authclient.Confi
authConfig.Log = cfg.Logger
authConfig.DialOpts = append(authConfig.DialOpts, metadata.WithUserAgentFromTeleportComponent(teleport.ComponentTCTL))
- return authConfig, nil
+ return &ResolvedConfig{Auth: authConfig}, nil
}
diff --git a/tool/tctl/common/config/profile.go b/tool/tctl/common/config/profile.go
index e11763411be..5a6fd23d9ce 100644
--- a/tool/tctl/common/config/profile.go
+++ b/tool/tctl/common/config/profile.go
@@ -38,8 +38,9 @@ import (
logutils "github.com/gravitational/teleport/lib/utils/log"
)
-// LoadConfigFromProfile applies config from ~/.tsh/ profile if it's present
-func LoadConfigFromProfile(ccf *GlobalCLIFlags, cfg *servicecfg.Config) (*authclient.Config, error) {
+// LoadFromProfileStore reads the FS- or identity-file-backed client store,
+// resolves its profile, and assembles a ResolvedConfig.
+func LoadFromProfileStore(ccf *GlobalCLIFlags, cfg *servicecfg.Config) (*ResolvedConfig, error) {
ctx := context.TODO()
proxyAddr := ""
if len(ccf.AuthServerAddr) != 0 {
@@ -119,5 +120,5 @@ func LoadConfigFromProfile(ccf *GlobalCLIFlags, cfg *servicecfg.Config) (*authcl
cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
}
- return authConfig, nil
+ return &ResolvedConfig{Auth: authConfig, ClientStore: clientStore, Profile: profile}, nil
}
diff --git a/tool/tctl/common/tctl_test.go b/tool/tctl/common/tctl_test.go
index e214ef75b5b..4e00d0ecd81 100644
--- a/tool/tctl/common/tctl_test.go
+++ b/tool/tctl/common/tctl_test.go
@@ -239,14 +239,25 @@ func TestConnect(t *testing.T) {
tc.modifyConfig(cfg)
}
- clientConfig, err := tctlcfg.ApplyConfig(&tc.cliFlags, cfg)
+ resolved, err := tctlcfg.ApplyConfig(&tc.cliFlags, cfg)
if tc.wantErrContains != "" {
require.ErrorContains(t, err, tc.wantErrContains)
return
}
require.NoError(t, err)
- _, err = authclient.Connect(ctx, clientConfig)
+ // Identity-file and tsh-profile paths populate ClientStore+Profile
+ // for downstream callers (e.g. Access Graph credential lookup);
+ // auth-host fallback leaves both nil.
+ if tc.cliFlags.IdentityFilePath != "" {
+ require.NotNil(t, resolved.ClientStore, "identity-file path must populate ClientStore")
+ require.NotNil(t, resolved.Profile, "identity-file path must populate Profile")
+ } else {
+ require.Nil(t, resolved.ClientStore, "auth-host path must leave ClientStore nil")
+ require.Nil(t, resolved.Profile, "auth-host path must leave Profile nil")
+ }
+
+ _, err = authclient.Connect(ctx, resolved.Auth)
require.NoError(t, err)
})
}
diff --git a/tool/tsh/common/tctl_test.go b/tool/tsh/common/tctl_test.go
index 13c9a763386..3a737094adc 100644
--- a/tool/tsh/common/tctl_test.go
+++ b/tool/tsh/common/tctl_test.go
@@ -35,7 +35,7 @@ import (
tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config"
)
-func TestLoadConfigFromProfile(t *testing.T) {
+func TestLoadFromProfileStore(t *testing.T) {
tmpHomePath := t.TempDir()
connector := mockConnector(t)
@@ -83,12 +83,15 @@ func TestLoadConfigFromProfile(t *testing.T) {
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
- _, err := tctlcfg.LoadConfigFromProfile(tc.ccf, tc.cfg)
+ resolved, err := tctlcfg.LoadFromProfileStore(tc.ccf, tc.cfg)
if tc.want != nil {
require.ErrorIs(t, err, tc.want)
return
}
require.NoError(t, err)
+ require.NotNil(t, resolved.Auth, "profile-store path must populate Auth")
+ require.NotNil(t, resolved.ClientStore, "profile-store path must populate ClientStore")
+ require.NotNil(t, resolved.Profile, "profile-store path must populate Profile")
})
}
}