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") }) } }