mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 10:10:29 +08:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user