Access Graph credential management plumbing for tctl (#66270)

* feat(tctl): credential management for Access Graph

Adds the `tool/tctl/common/accessgraph` package with the credential
helpers shared by the upcoming experimental Access Graph tctl
subcommands. Helpers only — no subcommand wiring lands here.

Surface:

  - `resolveAccessGraphCredentials`: looks up the keyring by the
    profile's `Name` / `Cluster` / `Username` and bundles it with the
    proxy address.
  - `ensureAccessGraphCert`: fast-paths on a valid cached cert,
    otherwise checks the precondition and re-issues via
    `GenerateUserCerts(Usage=AccessGraphAPI)`.
  - `validateAccessGraphCert{,Expiration,PrivateKey}`,
    `checkAccessGraphSupported`, `issueAccessGraphCert`,
    `issueAndStoreAccessGraphCert`.

Splitting the helpers from the command tree lets this code merge
ahead of #65949 (vendored Access Graph REST client), which is still
in review.

* fix: tighten TTL and share tctl config resolution

Address #66270 review feedback:
- Split the AG persistence floor (accessGraphMinPersistTTL = 5m)
  from the validity buffer (accessGraphCertExpiryBuffer = 2m).
- Add a separate auth-host credential resolver that skips disk
  persistence by returning clientStore = nil.
- Widen tctlcfg.ApplyConfig to return ResolvedConfig{Auth,
  ClientStore, Profile} so the upstream AG dispatcher reuses the
  same profile / identity-file / auth-host detection as every other
  tctl command.

* fix: resolve proxy address from auth Ping

Identity-file mode with `--auth-server=<host>:3025` was wrongly using
that auth address as the AG proxy address. Always backfill
`creds.proxyAddr` from `ping.GetProxyPublicAddr()` on the issue path;
non-`tsh login` resolvers leave it empty. Ping fetch consolidated to
a single call site so `checkAccessGraphSupported` becomes a pure
function over the response.

* fix: set proxy-url from ping when not already set

* fix: exercise private funcs and policy check

* fix: lint error kebab vs snake case

* refactor: drop auth-host flow for tctl ag

Issuing an Access Graph cert on the auth host requires picking an
cluster user to mint the certificate for. Though this is a `valid`
operation in terms of permissions, it's a bit of a footgun so at least
for now we are dropping support for it and replacing it with a more
detailed error message.

* chore: add missing space in auth host error
This commit is contained in:
Ghassan Gedeon Achi
2026-05-13 16:56:20 +00:00
committed by GitHub
parent 5f41d3eeda
commit c3d03ddce1
9 changed files with 1256 additions and 14 deletions
+103
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
+358
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
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 <accessgraph>` 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 <identity-file> --auth-server <proxy-or-auth-addr>`" + ` 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
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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=<host>: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)
}
}
})
}
}
+2 -1
View File
@@ -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,
+2
View File
@@ -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{},
+18 -6
View File
@@ -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
}
+4 -3
View File
@@ -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
}
+13 -2
View File
@@ -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)
})
}
+5 -2
View File
@@ -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")
})
}
}