From fef9904bde1c585bff6131277827155c7d8c2f62 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 8 Jan 2025 11:58:49 +0000 Subject: [PATCH 01/10] Add new config struct for `workload-identity-api` service --- lib/tbot/config/config.go | 6 + lib/tbot/config/config_test.go | 6 + .../config/service_workload_identity_api.go | 78 +++++++++++ .../service_workload_identity_api_test.go | 129 ++++++++++++++++++ .../TestBotConfig_YAML/standard_config.golden | 7 + .../full.golden | 13 ++ .../minimal.golden | 7 + 7 files changed, 246 insertions(+) create mode 100644 lib/tbot/config/service_workload_identity_api.go create mode 100644 lib/tbot/config/service_workload_identity_api_test.go create mode 100644 lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/full.golden create mode 100644 lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/minimal.golden diff --git a/lib/tbot/config/config.go b/lib/tbot/config/config.go index e064bfcc16f31..e62af153ffe48 100644 --- a/lib/tbot/config/config.go +++ b/lib/tbot/config/config.go @@ -405,6 +405,12 @@ func (o *ServiceConfigs) UnmarshalYAML(node *yaml.Node) error { return trace.Wrap(err) } out = append(out, v) + case WorkloadIdentityAPIServiceType: + v := &WorkloadIdentityAPIService{} + if err := node.Decode(v); err != nil { + return trace.Wrap(err) + } + out = append(out, v) default: return trace.BadParameter("unrecognized service type (%s)", header.Type) } diff --git a/lib/tbot/config/config_test.go b/lib/tbot/config/config_test.go index d1efb3b9320de..0696862fdacb6 100644 --- a/lib/tbot/config/config_test.go +++ b/lib/tbot/config/config_test.go @@ -272,6 +272,12 @@ func TestBotConfig_YAML(t *testing.T) { Name: "my-workload-identity", }, }, + &WorkloadIdentityAPIService{ + Listen: "tcp://127.0.0.1:123", + WorkloadIdentity: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + }, }, }, }, diff --git a/lib/tbot/config/service_workload_identity_api.go b/lib/tbot/config/service_workload_identity_api.go new file mode 100644 index 0000000000000..d68bfffebefa6 --- /dev/null +++ b/lib/tbot/config/service_workload_identity_api.go @@ -0,0 +1,78 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "github.com/gravitational/trace" + "gopkg.in/yaml.v3" + + "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" +) + +const WorkloadIdentityAPIServiceType = "workload-identity-api" + +var ( + _ ServiceConfig = &WorkloadIdentityAPIService{} +) + +// WorkloadIdentityAPIService is the configuration for the +// WorkloadIdentityAPIService +type WorkloadIdentityAPIService struct { + // Listen is the address on which the SPIFFE Workload API server should + // listen. This should either be prefixed with "unix://" or "tcp://". + Listen string `yaml:"listen"` + // Attestors is the configuration for the workload attestation process. + Attestors workloadattest.Config `yaml:"attestors"` + // WorkloadIdentity is the selector for the WorkloadIdentity resource that + // will be used to issue WICs. + WorkloadIdentity WorkloadIdentitySelector `yaml:"workload_identity"` +} + +// CheckAndSetDefaults checks the SPIFFESVIDOutput values and sets any defaults. +func (o *WorkloadIdentityAPIService) CheckAndSetDefaults() error { + if o.Listen == "" { + return trace.BadParameter("listen: should not be empty") + } + if err := o.Attestors.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err, "validating attestor") + } + if err := o.WorkloadIdentity.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err, "validating workload_identity") + } + return nil +} + +// Type returns the type of the service. +func (o *WorkloadIdentityAPIService) Type() string { + return WorkloadIdentityAPIServiceType +} + +// MarshalYAML marshals the WorkloadIdentityOutput into YAML. +func (o *WorkloadIdentityAPIService) MarshalYAML() (interface{}, error) { + type raw WorkloadIdentityAPIService + return withTypeHeader((*raw)(o), WorkloadIdentityAPIServiceType) +} + +// UnmarshalYAML unmarshals the WorkloadIdentityOutput from YAML. +func (o *WorkloadIdentityAPIService) UnmarshalYAML(node *yaml.Node) error { + // Alias type to remove UnmarshalYAML to avoid recursion + type raw WorkloadIdentityAPIService + if err := node.Decode((*raw)(o)); err != nil { + return trace.Wrap(err) + } + return nil +} diff --git a/lib/tbot/config/service_workload_identity_api_test.go b/lib/tbot/config/service_workload_identity_api_test.go new file mode 100644 index 0000000000000..cd668381beb16 --- /dev/null +++ b/lib/tbot/config/service_workload_identity_api_test.go @@ -0,0 +1,129 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package config + +import ( + "testing" + + "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" +) + +func TestWorkloadIdentityAPIService_YAML(t *testing.T) { + t.Parallel() + + tests := []testYAMLCase[WorkloadIdentityAPIService]{ + { + name: "full", + in: WorkloadIdentityAPIService{ + Listen: "tcp://0.0.0.0:4040", + Attestors: workloadattest.Config{ + Kubernetes: workloadattest.KubernetesAttestorConfig{ + Enabled: true, + Kubelet: workloadattest.KubeletClientConfig{ + SecurePort: 12345, + TokenPath: "/path/to/token", + CAPath: "/path/to/ca.pem", + SkipVerify: true, + Anonymous: true, + }, + }, + }, + WorkloadIdentity: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + }, + }, + { + name: "minimal", + in: WorkloadIdentityAPIService{ + Listen: "tcp://0.0.0.0:4040", + WorkloadIdentity: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + }, + }, + } + testYAML(t, tests) +} + +func TestWorkloadIdentityAPIService_CheckAndSetDefaults(t *testing.T) { + t.Parallel() + + tests := []testCheckAndSetDefaultsCase[*WorkloadIdentityAPIService]{ + { + name: "valid", + in: func() *WorkloadIdentityAPIService { + return &WorkloadIdentityAPIService{ + WorkloadIdentity: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + Listen: "tcp://0.0.0.0:4040", + } + }, + }, + { + name: "valid with labels", + in: func() *WorkloadIdentityAPIService { + return &WorkloadIdentityAPIService{ + WorkloadIdentity: WorkloadIdentitySelector{ + Labels: map[string][]string{ + "key": {"value"}, + }, + }, + Listen: "tcp://0.0.0.0:4040", + } + }, + }, + { + name: "missing selectors", + in: func() *WorkloadIdentityAPIService { + return &WorkloadIdentityAPIService{ + WorkloadIdentity: WorkloadIdentitySelector{}, + Listen: "tcp://0.0.0.0:4040", + } + }, + wantErr: "one of ['name', 'labels'] must be set", + }, + { + name: "too many selectors", + in: func() *WorkloadIdentityAPIService { + return &WorkloadIdentityAPIService{ + WorkloadIdentity: WorkloadIdentitySelector{ + Name: "my-workload-identity", + Labels: map[string][]string{ + "key": {"value"}, + }, + }, + Listen: "tcp://0.0.0.0:4040", + } + }, + wantErr: "at most one of ['name', 'labels'] can be set", + }, + { + name: "missing listen", + in: func() *WorkloadIdentityAPIService { + return &WorkloadIdentityAPIService{ + WorkloadIdentity: WorkloadIdentitySelector{ + Name: "my-workload-identity", + }, + } + }, + wantErr: "listen: should not be empty", + }, + } + testCheckAndSetDefaults(t, tests) +} diff --git a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden index 45af51d235ede..0ef46a5261d1d 100644 --- a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden +++ b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden @@ -61,6 +61,13 @@ services: destination: type: directory path: /an/output/path + - type: workload-identity-api + listen: tcp://127.0.0.1:123 + attestors: + kubernetes: + enabled: false + workload_identity: + name: my-workload-identity debug: true auth_server: example.teleport.sh:443 certificate_ttl: 1m0s diff --git a/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/full.golden b/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/full.golden new file mode 100644 index 0000000000000..a46e2c820806e --- /dev/null +++ b/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/full.golden @@ -0,0 +1,13 @@ +type: workload-identity-api +listen: tcp://0.0.0.0:4040 +attestors: + kubernetes: + enabled: true + kubelet: + secure_port: 12345 + token_path: /path/to/token + ca_path: /path/to/ca.pem + skip_verify: true + anonymous: true +workload_identity: + name: my-workload-identity diff --git a/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/minimal.golden b/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/minimal.golden new file mode 100644 index 0000000000000..dacfe222089fc --- /dev/null +++ b/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/minimal.golden @@ -0,0 +1,7 @@ +type: workload-identity-api +listen: tcp://0.0.0.0:4040 +attestors: + kubernetes: + enabled: false +workload_identity: + name: my-workload-identity From b8b802d69c20344c094ea32f22b1e493468542fa Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 8 Jan 2025 12:21:06 +0000 Subject: [PATCH 02/10] Add CLI command --- lib/tbot/cli/start_workload_identity_api.go | 107 ++++++++++++++++++ .../cli/start_workload_identity_api_test.go | 75 ++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 lib/tbot/cli/start_workload_identity_api.go create mode 100644 lib/tbot/cli/start_workload_identity_api_test.go diff --git a/lib/tbot/cli/start_workload_identity_api.go b/lib/tbot/cli/start_workload_identity_api.go new file mode 100644 index 0000000000000..34df4ebc9e48a --- /dev/null +++ b/lib/tbot/cli/start_workload_identity_api.go @@ -0,0 +1,107 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cli + +import ( + "fmt" + "log/slog" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/tbot/config" +) + +// WorkloadIdentityAPICommand implements `tbot start workload-identity-api` and +// `tbot configure workload-identity-api`. +type WorkloadIdentityAPICommand struct { + *sharedStartArgs + *genericMutatorHandler + + // Listen configures where the workload identity API should listen. This + // should be prefixed with a scheme e.g unix:// or tcp://. + Listen string + // WorkloadIdentityName is the name of the workload identity to use. + // --workload-identity-name foo + WorkloadIdentityName string + // WorkloadIdentityLabels is the labels of the workload identity to use. + // --workload-identity-labels x=y,z=a + WorkloadIdentityLabels string +} + +// NewWorkloadIdentityAPICommand initializes the command and flags for the +// `workload-identity-api` service and returns a struct that will contain the +// parse result. +func NewWorkloadIdentityAPICommand(parentCmd *kingpin.CmdClause, action MutatorAction, mode CommandMode) *WorkloadIdentityAPICommand { + // TODO(noah): Unhide this command when feature flag removed + cmd := parentCmd.Command( + "workload-identity-api", + fmt.Sprintf("%s tbot with a workload identity API listener. Compatible with the SPIFFE Workload API and Envoy SDS.", mode), + ).Hidden() + + c := &WorkloadIdentityAPICommand{} + c.sharedStartArgs = newSharedStartArgs(cmd) + c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action) + + cmd.Flag( + "workload-identity-name", + "The name of the workload identity to issue", + ).StringVar(&c.WorkloadIdentityName) + cmd.Flag( + "workload-identity-labels", + "A label-based selector for which workload identities to issue. Multiple labels can be provided using ','.", + ).StringVar(&c.WorkloadIdentityLabels) + cmd.Flag( + "listen-addr", + "The address on which the workload identity API should listen. This should either be prefixed with 'unix://' or 'tcp://'.", + ).Required().StringVar(&c.Listen) + + return c +} + +func (c *WorkloadIdentityAPICommand) ApplyConfig(cfg *config.BotConfig, l *slog.Logger) error { + if err := c.sharedStartArgs.ApplyConfig(cfg, l); err != nil { + return trace.Wrap(err) + } + + svc := &config.WorkloadIdentityAPIService{ + Listen: c.Listen, + } + + switch { + case c.WorkloadIdentityName != "" && c.WorkloadIdentityLabels != "": + return trace.BadParameter("workload-identity-name and workload-identity-labels flags are mutually exclusive") + case c.WorkloadIdentityName != "": + svc.WorkloadIdentity.Name = c.WorkloadIdentityName + case c.WorkloadIdentityLabels != "": + labels, err := client.ParseLabelSpec(c.WorkloadIdentityLabels) + if err != nil { + return trace.Wrap(err, "parsing --workload-identity-labels") + } + svc.WorkloadIdentity.Labels = map[string][]string{} + for k, v := range labels { + svc.WorkloadIdentity.Labels[k] = []string{v} + } + default: + return trace.BadParameter("workload-identity-name or workload-identity-labels must be specified") + } + + cfg.Services = append(cfg.Services, svc) + + return nil +} diff --git a/lib/tbot/cli/start_workload_identity_api_test.go b/lib/tbot/cli/start_workload_identity_api_test.go new file mode 100644 index 0000000000000..b30a0f2fede4d --- /dev/null +++ b/lib/tbot/cli/start_workload_identity_api_test.go @@ -0,0 +1,75 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package cli + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/tbot/config" +) + +func TestNewWorkloadIdentityAPICommand(t *testing.T) { + testStartConfigureCommand(t, NewWorkloadIdentityAPICommand, []startConfigureTestCase{ + { + name: "success", + args: []string{ + "start", + "workload-identity-api", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--listen-addr=tcp://0.0.0.0:8080", + "--workload-identity-labels=*=*,foo=bar", + }, + assertConfig: func(t *testing.T, cfg *config.BotConfig) { + require.Len(t, cfg.Services, 1) + + svc := cfg.Services[0] + wis, ok := svc.(*config.WorkloadIdentityAPIService) + require.True(t, ok) + require.Equal(t, "tcp://0.0.0.0:8080", wis.Listen) + require.Equal(t, map[string][]string{ + "*": {"*"}, + "foo": {"bar"}, + }, wis.WorkloadIdentity.Labels) + }, + }, + { + name: "success name selector", + args: []string{ + "start", + "workload-identity-api", + "--token=foo", + "--join-method=github", + "--proxy-server=example.com:443", + "--listen-addr=unix:///opt/workload.sock", + "--workload-identity-name=jim", + }, + assertConfig: func(t *testing.T, cfg *config.BotConfig) { + require.Len(t, cfg.Services, 1) + + svc := cfg.Services[0] + wis, ok := svc.(*config.WorkloadIdentityAPIService) + require.True(t, ok) + require.Equal(t, "unix:///opt/workload.sock", wis.Listen) + require.Equal(t, "jim", wis.WorkloadIdentity.Name) + }, + }, + }) +} From 9f24acb4b082d4ad098d383a4d1ae7a02373bbc5 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 8 Jan 2025 14:09:00 +0000 Subject: [PATCH 03/10] Add API --- lib/tbot/service_workload_identity_api.go | 702 ++++++++++++++++++++++ lib/tbot/tbot.go | 32 + 2 files changed, 734 insertions(+) create mode 100644 lib/tbot/service_workload_identity_api.go diff --git a/lib/tbot/service_workload_identity_api.go b/lib/tbot/service_workload_identity_api.go new file mode 100644 index 0000000000000..927922a626d4e --- /dev/null +++ b/lib/tbot/service_workload_identity_api.go @@ -0,0 +1,702 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tbot + +import ( + "context" + "crypto/x509" + "fmt" + "log/slog" + "time" + + secretv3pb "github.com/envoyproxy/go-control-plane/envoy/service/secret/v3" + "github.com/gravitational/trace" + "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/recovery" + "github.com/prometheus/client_golang/prometheus" + "github.com/spiffe/go-spiffe/v2/bundle/spiffebundle" + workloadpb "github.com/spiffe/go-spiffe/v2/proto/spiffe/workload" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/go-spiffe/v2/svid/jwtsvid" + "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" + "golang.org/x/sync/errgroup" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/gravitational/teleport" + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/observability/metrics" + "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/tbot/spiffe" + "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" + "github.com/gravitational/teleport/lib/uds" +) + +// WorkloadIdentityAPIService implements a gRPC server that fulfills the SPIFFE +// Workload API specification. It provides X509 SVIDs and trust bundles to +// workloads that connect over the configured listener. +// +// Sources: +// - https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Workload_Endpoint.md +// - https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Workload_API.md +// - https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md +// - https://github.com/spiffe/spiffe/blob/main/standards/X509-SVID.md +type WorkloadIdentityAPIService struct { + workloadpb.UnimplementedSpiffeWorkloadAPIServer + + svcIdentity *config.UnstableClientCredentialOutput + botCfg *config.BotConfig + cfg *config.WorkloadIdentityAPIService + log *slog.Logger + resolver reversetunnelclient.Resolver + trustBundleCache *spiffe.TrustBundleCache + + // client holds the impersonated client for the service + client *authclient.Client + attestor *workloadattest.Attestor + localTrustDomain spiffeid.TrustDomain +} + +// setup initializes the service, performing tasks such as determining the +// trust domain, fetching the initial trust bundle and creating an impersonated +// client. +func (s *WorkloadIdentityAPIService) setup(ctx context.Context) (err error) { + ctx, span := tracer.Start(ctx, "WorkloadIdentityAPIService/setup") + defer span.End() + + // Wait for the impersonated identity to be ready for us to consume here. + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Second): + return trace.BadParameter("timeout waiting for identity to be ready") + case <-s.svcIdentity.Ready(): + } + facade, err := s.svcIdentity.Facade() + if err != nil { + return trace.Wrap(err) + } + client, err := clientForFacade( + ctx, s.log, s.botCfg, facade, s.resolver, + ) + if err != nil { + return trace.Wrap(err) + } + s.client = client + // Closure is managed by the caller if this function succeeds. But if it + // fails, we need to close the client. + defer func() { + if err != nil { + client.Close() + } + }() + + td, err := spiffeid.TrustDomainFromString(facade.Get().ClusterName) + if err != nil { + return trace.Wrap(err, "parsing trust domain name") + } + s.localTrustDomain = td + + s.attestor, err = workloadattest.NewAttestor(s.log, s.cfg.Attestors) + if err != nil { + return trace.Wrap(err, "setting up workload attestation") + } + + return nil +} + +func (s *WorkloadIdentityAPIService) Run(ctx context.Context) error { + ctx, span := tracer.Start(ctx, "WorkloadIdentityAPIService/Run") + defer span.End() + + s.log.DebugContext(ctx, "Starting pre-run initialization") + if err := s.setup(ctx); err != nil { + return trace.Wrap(err) + } + defer s.client.Close() + s.log.DebugContext(ctx, "Completed pre-run initialization") + + srvMetrics := metrics.CreateGRPCServerMetrics( + true, prometheus.Labels{ + teleport.TagServer: "tbot-workload-identity-api", + }, + ) + if err := metrics.RegisterPrometheusCollectors(srvMetrics); err != nil { + return trace.Wrap(err) + } + srv := grpc.NewServer( + grpc.Creds( + // SPEC (SPIFFE_Workload_endpoint) 3. Transport: + // - Transport Layer Security MUST NOT be required + // TODO(noah): We should optionally provide TLS support here down + // the road. + uds.NewTransportCredentials(insecure.NewCredentials()), + ), + grpc.ChainUnaryInterceptor( + recovery.UnaryServerInterceptor(), + srvMetrics.UnaryServerInterceptor(), + ), + grpc.ChainStreamInterceptor( + recovery.StreamServerInterceptor(), + srvMetrics.StreamServerInterceptor(), + ), + grpc.StatsHandler(otelgrpc.NewServerHandler()), + grpc.MaxConcurrentStreams(defaults.GRPCMaxConcurrentStreams), + ) + workloadpb.RegisterSpiffeWorkloadAPIServer(srv, s) + sdsHandler := &spiffeSDSHandler{ + log: s.log, + botCfg: s.botCfg, + trustBundleCache: s.trustBundleCache, + clientAuthenticator: func(ctx context.Context) (*slog.Logger, svidFetcher, error) { + log, attrs, err := s.authenticateClient(ctx) + if err != nil { + return log, nil, trace.Wrap(err, "authenticating client") + } + + fetchSVIDs := func( + ctx context.Context, + localBundle *spiffebundle.Bundle, + ) ([]*workloadpb.X509SVID, error) { + return s.fetchX509SVIDs(ctx, log, localBundle, attrs) + } + + return log, fetchSVIDs, nil + }, + } + secretv3pb.RegisterSecretDiscoveryServiceServer(srv, sdsHandler) + + lis, err := createListener(ctx, s.log, s.cfg.Listen) + if err != nil { + return trace.Wrap(err, "creating listener") + } + defer func() { + if err := lis.Close(); err != nil { + s.log.ErrorContext(ctx, "Encountered error closing listener", "error", err) + } + }() + s.log.InfoContext(ctx, "Listener opened for Workload API endpoint", "addr", lis.Addr().String()) + if lis.Addr().Network() == "tcp" { + s.log.WarnContext( + ctx, "Workload API endpoint listening on a TCP port. Ensure that only intended hosts can reach this port!", + ) + } + + // Set off the long running tasks in an errgroup + eg, egCtx := errgroup.WithContext(ctx) + eg.Go(func() error { + // Start the gRPC server + return srv.Serve(lis) + }) + eg.Go(func() error { + // Shutdown the server when the context is canceled + <-egCtx.Done() + s.log.DebugContext(ctx, "Shutting down Workload API endpoint") + srv.Stop() + s.log.InfoContext(ctx, "Shut down Workload API endpoint") + return nil + }) + + return trace.Wrap(eg.Wait()) +} + +func (s *WorkloadIdentityAPIService) authenticateClient( + ctx context.Context, +) (*slog.Logger, *workloadidentityv1pb.WorkloadAttrs, error) { + p, ok := peer.FromContext(ctx) + if !ok { + return nil, nil, trace.BadParameter("peer not found in context") + } + log := s.log + + if p.Addr.String() != "" { + log = log.With( + slog.String("remote_addr", p.Addr.String()), + ) + } + + authInfo, ok := p.AuthInfo.(uds.AuthInfo) + // We expect Creds to be nil/unset if the client is connecting via TCP and + // therefore there is no workload attestation that can be completed. + if !ok || authInfo.Creds == nil { + return log, nil, nil + } + + // For a UDS, sometimes we are unable to determine the PID of the calling + // workload. This can happen if the caller is calling from another process + // namespace. In this case, Creds will be non-nil but the PID will be 0. + // + // We should fail softly here as there could be SVIDs that do not require + // workload attestation. + if authInfo.Creds.PID == 0 { + log.DebugContext( + ctx, "Failed to determine the PID of the calling workload. TBot may be running in a different process namespace to the workload. Workload attestation will not be completed.") + return log, nil, nil + } + + att, err := s.attestor.Attest(ctx, authInfo.Creds.PID) + if err != nil { + // Fail softly as there may be SVIDs configured that don't require any + // workload attestation and we should still issue those. + log.ErrorContext( + ctx, + "Workload attestation failed", + "error", err, + "pid", authInfo.Creds.PID, + ) + return log, nil, nil + } + log = log.With( + "workload", att, + ) + + return log, att, nil +} + +// FetchX509SVID generates and returns the X.509 SVIDs available to a workload. +// It is a streaming RPC, and sends renewed SVIDs to the client before they +// expire. +// Implements the SPIFFE Workload API FetchX509SVID method. +func (s *WorkloadIdentityAPIService) FetchX509SVID( + _ *workloadpb.X509SVIDRequest, + srv workloadpb.SpiffeWorkloadAPI_FetchX509SVIDServer, +) error { + ctx := srv.Context() + + log, creds, err := s.authenticateClient(ctx) + if err != nil { + return trace.Wrap(err, "authenticating client") + } + + log.InfoContext(ctx, "FetchX509SVID stream opened by workload") + defer log.InfoContext(ctx, "FetchX509SVID stream has closed") + + bundleSet, err := s.trustBundleCache.GetBundleSet(ctx) + if err != nil { + return trace.Wrap(err) + } + + var svids []*workloadpb.X509SVID + for { + log.InfoContext(ctx, "Starting to issue X509 SVIDs to workload") + + // Fetch SVIDs if necessary. + if svids == nil { + svids, err = s.fetchX509SVIDs(ctx, log, bundleSet.Local, creds) + if err != nil { + return trace.Wrap(err) + } + // The SPIFFE Workload API (5.2.1): + // + // If the client is not entitled to receive any X509-SVIDs, then the + // server SHOULD respond with the "PermissionDenied" gRPC status code (see + // the Error Codes section in the SPIFFE Workload Endpoint specification + // for more information). Under such a case, the client MAY attempt to + // reconnect with another call to the FetchX509SVID RPC after a backoff. + if len(svids) == 0 { + log.ErrorContext(ctx, "Workload did not pass attestation for any SVIDs") + return status.Error( + codes.PermissionDenied, + "workload did not pass attestation for any SVIDs", + ) + } + + } + err = srv.Send(&workloadpb.X509SVIDResponse{ + Svids: svids, + FederatedBundles: bundleSet.EncodedX509Bundles(false), + }) + if err != nil { + return trace.Wrap(err) + } + log.DebugContext( + ctx, "Finished issuing SVIDs to workload. Waiting for next renewal interval or CA rotation", + ) + + select { + case <-ctx.Done(): + log.DebugContext(ctx, "Context closed, stopping SVID stream") + return nil + case <-bundleSet.Stale(): + newBundleSet, err := s.trustBundleCache.GetBundleSet(ctx) + if err != nil { + return trace.Wrap(err) + } + log.DebugContext(ctx, "Federated trust bundles have been updated, renewing SVIDs") + if !newBundleSet.Local.Equal(bundleSet.Local) { + // If the "local" trust domain's CA has changed, we need to + // reissue the SVIDs. + svids = nil + } + bundleSet = newBundleSet + continue + case <-time.After(s.botCfg.RenewalInterval): + log.DebugContext(ctx, "Renewal interval reached, renewing SVIDs") + svids = nil + continue + } + } +} + +// FetchX509Bundles returns the trust bundle for the trust domain. It is a +// streaming RPC, and will send rotated trust bundles to the client for as long +// as the client is connected. +// Implements the SPIFFE Workload API FetchX509SVID method. +func (s *WorkloadIdentityAPIService) FetchX509Bundles( + _ *workloadpb.X509BundlesRequest, + srv workloadpb.SpiffeWorkloadAPI_FetchX509BundlesServer, +) error { + ctx := srv.Context() + s.log.InfoContext(ctx, "FetchX509Bundles stream opened by workload") + defer s.log.InfoContext(ctx, "FetchX509Bundles stream has closed") + + for { + bundleSet, err := s.trustBundleCache.GetBundleSet(ctx) + if err != nil { + return trace.Wrap(err) + } + + s.log.InfoContext(ctx, "Sending X.509 trust bundles to workload") + err = srv.Send(&workloadpb.X509BundlesResponse{ + Bundles: bundleSet.EncodedX509Bundles(true), + }) + if err != nil { + return trace.Wrap(err) + } + + select { + case <-ctx.Done(): + return nil + case <-bundleSet.Stale(): + } + } +} + +// fetchX509SVIDs fetches the X.509 SVIDs for the bot's configured SVIDs and +// returns them in the SPIFFE Workload API format. +func (s *WorkloadIdentityAPIService) fetchX509SVIDs( + ctx context.Context, + log *slog.Logger, + localBundle *spiffebundle.Bundle, + attest *workloadidentityv1pb.WorkloadAttrs, +) ([]*workloadpb.X509SVID, error) { + ctx, span := tracer.Start(ctx, "WorkloadIdentityAPIService/fetchX509SVIDs") + defer span.End() + + creds, privateKey, err := issueX509WorkloadIdentity( + ctx, + log, + s.client, + s.cfg.WorkloadIdentity, + s.botCfg.CertificateTTL, + attest, + ) + if err != nil { + return nil, trace.Wrap(err) + } + + // Convert the private key to PKCS#8 format as per SPIFFE spec. + pkcs8PrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, trace.Wrap(err) + } + + marshaledBundle := spiffe.MarshalX509Bundle(localBundle.X509Bundle()) + + // Convert responses from the Teleport API to the SPIFFE Workload API + // format. + svids := make([]*workloadpb.X509SVID, len(creds)) + for i, cred := range creds { + svids[i] = &workloadpb.X509SVID{ + // Required. The SPIFFE ID of the SVID in this entry + SpiffeId: cred.SpiffeId, + // Required. ASN.1 DER encoded certificate chain. MAY include + // intermediates, the leaf certificate (or SVID itself) MUST come first. + X509Svid: cred.GetX509Svid().GetCert(), + // Required. ASN.1 DER encoded PKCS#8 private key. MUST be unencrypted. + X509SvidKey: pkcs8PrivateKey, + // Required. ASN.1 DER encoded X.509 bundle for the trust domain. + Bundle: marshaledBundle, + Hint: cred.Hint, + } + // Log a message which correlates with the audit log entry and can + // provide additional metadata about the client. + log.InfoContext(ctx, + "Issued Workload Identity Credential", + slog.Group("credential", + "type", "x509-svid", + "spiffe_id", cred.SpiffeId, + "serial_number", cred.GetX509Svid().GetSerialNumber(), + "hint", cred.Hint, + "expires_at", cred.ExpiresAt, + "ttl", cred.Ttl, + "workload_identity_name", cred.WorkloadIdentityName, + "workload_identity_revision", cred.WorkloadIdentityRevision, + ), + ) + } + + return svids, nil +} + +// FetchJWTSVID implements the SPIFFE Workload API FetchJWTSVID method. +// See The SPIFFE Workload API (6.2.1). +func (s *WorkloadIdentityAPIService) FetchJWTSVID( + ctx context.Context, + req *workloadpb.JWTSVIDRequest, +) (*workloadpb.JWTSVIDResponse, error) { + log, attr, err := s.authenticateClient(ctx) + if err != nil { + return nil, trace.Wrap(err, "authenticating client") + } + + log.InfoContext(ctx, "FetchJWTSVID request received from workload") + defer log.InfoContext(ctx, "FetchJWTSVID request handled") + if req.SpiffeId == "" { + log = log.With("requested_spiffe_id", req.SpiffeId) + } + + // The SPIFFE Workload API (6.2.1): + // > The JWTSVIDRequest request message contains a mandatory audience field, + // > which MUST contain the value to embed in the audience claim of the + // > returned JWT-SVIDs. + if len(req.Audience) == 0 { + return nil, trace.BadParameter("audience: must have at least one value") + } + + creds, err := issueJWTWorkloadIdentity( + ctx, + s.client, + s.cfg.WorkloadIdentity, + req.Audience, + s.botCfg.CertificateTTL, + attr, + ) + if err != nil { + return nil, trace.Wrap(err) + } + + // The SPIFFE Workload API (6.2.1): + // > If the client is not authorized for any identities, or not authorized + // > for the specific identity requested via the spiffe_id field, then the + // > server SHOULD respond with the "PermissionDenied" gRPC status code. + if len(creds) == 0 { + log.ErrorContext(ctx, "Workload did not pass attestation for any SVIDs") + return nil, status.Error( + codes.PermissionDenied, + "workload did not pass attestation for any SVIDs", + ) + } + + svids := []*workloadpb.JWTSVID{} + for _, cred := range creds { + svids = append(svids, &workloadpb.JWTSVID{ + SpiffeId: cred.SpiffeId, + Svid: cred.GetJwtSvid().GetJwt(), + Hint: cred.Hint, + }) + log.InfoContext(ctx, + "Issued Workload Identity Credential", + slog.Group("credential", + "type", "jwt-svid", + "spiffe_id", cred.SpiffeId, + "jti", cred.GetJwtSvid().GetJti(), + "hint", cred.Hint, + "expires_at", cred.ExpiresAt, + "ttl", cred.Ttl, + "audiences", req.Audience, + ), + ) + } + + // The SPIFFE Workload API (6.2.1): + // > The spiffe_id field is optional, and is used to request a JWT-SVID for + // > a specific SPIFFE ID. If unspecified, the server MUST return JWT-SVIDs + // > for all identities authorized for the client. + // TODO(noah): We should optimize here by making the Teleport + // WorkloadIdentityIssuance API aware of the requested SPIFFE ID. Theres's + // no point signing a credential to just bin it here... + if req.SpiffeId != "" { + requestedSPIFFEID, err := spiffeid.FromString(req.SpiffeId) + if err != nil { + return nil, trace.Wrap(err, "parsing requested SPIFFE ID") + } + if requestedSPIFFEID.TrustDomain() != s.localTrustDomain { + return nil, trace.BadParameter("requested SPIFFE ID is not in the local trust domain") + } + + // Search through available SVIDs to find the one that matches the + // requested SPIFFE ID. + found := false + for _, svid := range svids { + if svid.SpiffeId == req.SpiffeId { + found = true + svids = []*workloadpb.JWTSVID{svid} + break + } + } + if !found { + log.ErrorContext(ctx, "Workload is not authorized for the specifically requested SPIFFE ID", "requested_spiffe_id", req.SpiffeId) + return nil, status.Error( + codes.PermissionDenied, + "workload is not authorized for requested SPIFFE ID", + ) + } + } + + return &workloadpb.JWTSVIDResponse{ + Svids: svids, + }, nil +} + +// FetchJWTBundles implements the SPIFFE Workload API FetchJWTBundles method. +// See The SPIFFE Workload API (6.2.2). +func (s *WorkloadIdentityAPIService) FetchJWTBundles( + _ *workloadpb.JWTBundlesRequest, + srv workloadpb.SpiffeWorkloadAPI_FetchJWTBundlesServer, +) error { + ctx := srv.Context() + s.log.InfoContext(ctx, "FetchJWTBundles stream started by workload") + defer s.log.InfoContext(ctx, "FetchJWTBundles stream ended") + + for { + bundleSet, err := s.trustBundleCache.GetBundleSet(ctx) + if err != nil { + return trace.Wrap(err) + } + + s.log.InfoContext(ctx, "Sending JWT trust bundles to workload") + + // The SPIFFE Workload API (6.2.2): + // > The returned bundles are encoded as a standard JWK Set as defined + // > by RFC 7517 containing the JWT-SVID signing keys for the trust + // > domain. These keys may only represent a subset of the keys present + // > in the SPIFFE trust bundle for the trust domain. The server MUST + // > NOT include keys with other uses in the returned JWT bundles. + bundles, err := bundleSet.MarshaledJWKSBundles(true) + if err != nil { + return trace.Wrap(err, "marshaling bundles as JWKS") + } + err = srv.Send(&workloadpb.JWTBundlesResponse{ + Bundles: bundles, + }) + if err != nil { + return trace.Wrap(err) + } + + select { + case <-ctx.Done(): + return nil + case <-bundleSet.Stale(): + } + } +} + +// ValidateJWTSVID implements the SPIFFE Workload API ValidateJWTSVID method. +// See The SPIFFE Workload API (6.2.3). +func (s *WorkloadIdentityAPIService) ValidateJWTSVID( + ctx context.Context, + req *workloadpb.ValidateJWTSVIDRequest, +) (*workloadpb.ValidateJWTSVIDResponse, error) { + s.log.InfoContext(ctx, "ValidateJWTSVID request received from workload") + defer s.log.InfoContext(ctx, "ValidateJWTSVID request handled") + + // The SPIFFE Workload API (6.2.3): + // > All fields in the ValidateJWTSVIDRequest and ValidateJWTSVIDResponse + // > message are mandatory. + switch { + case req.Audience == "": + return nil, trace.BadParameter("audience: must be set") + case req.Svid == "": + return nil, trace.BadParameter("svid: must be set") + } + + bundleSet, err := s.trustBundleCache.GetBundleSet(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + svid, err := jwtsvid.ParseAndValidate( + req.Svid, bundleSet, []string{req.Audience}, + ) + if err != nil { + return nil, trace.Wrap(err, "validating JWT SVID") + } + + claims, err := structpb.NewStruct(svid.Claims) + if err != nil { + return nil, trace.Wrap(err, "marshaling claims") + } + + return &workloadpb.ValidateJWTSVIDResponse{ + SpiffeId: svid.ID.String(), + Claims: claims, + }, nil +} + +// String returns a human-readable string that can uniquely identify the +// service. +func (s *WorkloadIdentityAPIService) String() string { + return fmt.Sprintf("%s:%s", config.WorkloadIdentityAPIServiceType, s.cfg.Listen) +} + +func issueJWTWorkloadIdentity( + ctx context.Context, + clt *authclient.Client, + workloadIdentity config.WorkloadIdentitySelector, + audiences []string, + ttl time.Duration, + attest *workloadidentityv1pb.WorkloadAttrs, +) ([]*workloadidentityv1pb.Credential, error) { + ctx, span := tracer.Start( + ctx, + "issueJWTWorkloadIdentity", + ) + defer span.End() + + if len(audiences) == 0 { + return nil, nil + } + + // When using the "name" based selector, we either get a single WIC back, + // or an error. We don't need to worry about selecting the right one. + res, err := clt.WorkloadIdentityIssuanceClient().IssueWorkloadIdentity(ctx, + &workloadidentityv1pb.IssueWorkloadIdentityRequest{ + Name: workloadIdentity.Name, + Credential: &workloadidentityv1pb.IssueWorkloadIdentityRequest_JwtSvidParams{ + JwtSvidParams: &workloadidentityv1pb.JWTSVIDParams{ + Audiences: audiences, + }, + }, + RequestedTtl: durationpb.New(ttl), + WorkloadAttrs: attest, + }, + ) + if err != nil { + return nil, trace.Wrap(err) + } + // TODO: Log intimate details of the issued credential + + return []*workloadidentityv1pb.Credential{res.Credential}, nil +} diff --git a/lib/tbot/tbot.go b/lib/tbot/tbot.go index 4d20ffdceba5e..cf0e38b0b2e72 100644 --- a/lib/tbot/tbot.go +++ b/lib/tbot/tbot.go @@ -505,6 +505,38 @@ func (b *Bot) Run(ctx context.Context) (err error) { svc.trustBundleCache = tbCache } services = append(services, svc) + case *config.WorkloadIdentityAPIService: + clientCredential := &config.UnstableClientCredentialOutput{} + svcIdentity := &ClientCredentialOutputService{ + botAuthClient: b.botIdentitySvc.GetClient(), + botCfg: b.cfg, + cfg: clientCredential, + getBotIdentity: b.botIdentitySvc.GetIdentity, + reloadBroadcaster: reloadBroadcaster, + } + svcIdentity.log = b.log.With( + teleport.ComponentKey, teleport.Component( + componentTBot, "svc", svcIdentity.String(), + ), + ) + services = append(services, svcIdentity) + + tbCache, err := setupTrustBundleCache() + if err != nil { + return trace.Wrap(err) + } + + svc := &WorkloadIdentityAPIService{ + svcIdentity: clientCredential, + botCfg: b.cfg, + cfg: svcCfg, + resolver: resolver, + trustBundleCache: tbCache, + } + svc.log = b.log.With( + teleport.ComponentKey, teleport.Component(componentTBot, "svc", svc.String()), + ) + services = append(services, svc) default: return trace.BadParameter("unknown service type: %T", svcCfg) } From 18408f0314a5dced05724988375ec8664eb572c8 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Wed, 8 Jan 2025 17:42:12 +0000 Subject: [PATCH 04/10] Wire up API --- .../config/service_workload_identity_api.go | 2 +- .../service_workload_identity_api_test.go | 2 +- lib/tbot/service_workload_identity_api.go | 54 +------- lib/tbot/workloadidentity/issue.go | 126 ++++++++++++++++-- 4 files changed, 124 insertions(+), 60 deletions(-) diff --git a/lib/tbot/config/service_workload_identity_api.go b/lib/tbot/config/service_workload_identity_api.go index d68bfffebefa6..cabdd6b19c911 100644 --- a/lib/tbot/config/service_workload_identity_api.go +++ b/lib/tbot/config/service_workload_identity_api.go @@ -20,7 +20,7 @@ import ( "github.com/gravitational/trace" "gopkg.in/yaml.v3" - "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" + "github.com/gravitational/teleport/lib/tbot/workloadidentity/workloadattest" ) const WorkloadIdentityAPIServiceType = "workload-identity-api" diff --git a/lib/tbot/config/service_workload_identity_api_test.go b/lib/tbot/config/service_workload_identity_api_test.go index cd668381beb16..da2b6330229ee 100644 --- a/lib/tbot/config/service_workload_identity_api_test.go +++ b/lib/tbot/config/service_workload_identity_api_test.go @@ -19,7 +19,7 @@ package config import ( "testing" - "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" + "github.com/gravitational/teleport/lib/tbot/workloadidentity/workloadattest" ) func TestWorkloadIdentityAPIService_YAML(t *testing.T) { diff --git a/lib/tbot/service_workload_identity_api.go b/lib/tbot/service_workload_identity_api.go index 927922a626d4e..132b0a60b021c 100644 --- a/lib/tbot/service_workload_identity_api.go +++ b/lib/tbot/service_workload_identity_api.go @@ -38,7 +38,6 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/peer" "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" "github.com/gravitational/teleport" @@ -48,8 +47,8 @@ import ( "github.com/gravitational/teleport/lib/observability/metrics" "github.com/gravitational/teleport/lib/reversetunnelclient" "github.com/gravitational/teleport/lib/tbot/config" - "github.com/gravitational/teleport/lib/tbot/spiffe" - "github.com/gravitational/teleport/lib/tbot/spiffe/workloadattest" + "github.com/gravitational/teleport/lib/tbot/workloadidentity" + "github.com/gravitational/teleport/lib/tbot/workloadidentity/workloadattest" "github.com/gravitational/teleport/lib/uds" ) @@ -70,7 +69,7 @@ type WorkloadIdentityAPIService struct { cfg *config.WorkloadIdentityAPIService log *slog.Logger resolver reversetunnelclient.Resolver - trustBundleCache *spiffe.TrustBundleCache + trustBundleCache *workloadidentity.TrustBundleCache // client holds the impersonated client for the service client *authclient.Client @@ -404,7 +403,7 @@ func (s *WorkloadIdentityAPIService) fetchX509SVIDs( ctx, span := tracer.Start(ctx, "WorkloadIdentityAPIService/fetchX509SVIDs") defer span.End() - creds, privateKey, err := issueX509WorkloadIdentity( + creds, privateKey, err := workloadidentity.IssueX509WorkloadIdentity( ctx, log, s.client, @@ -422,7 +421,7 @@ func (s *WorkloadIdentityAPIService) fetchX509SVIDs( return nil, trace.Wrap(err) } - marshaledBundle := spiffe.MarshalX509Bundle(localBundle.X509Bundle()) + marshaledBundle := workloadidentity.MarshalX509Bundle(localBundle.X509Bundle()) // Convert responses from the Teleport API to the SPIFFE Workload API // format. @@ -485,8 +484,9 @@ func (s *WorkloadIdentityAPIService) FetchJWTSVID( return nil, trace.BadParameter("audience: must have at least one value") } - creds, err := issueJWTWorkloadIdentity( + creds, err := workloadidentity.IssueJWTWorkloadIdentity( ctx, + log, s.client, s.cfg.WorkloadIdentity, req.Audience, @@ -660,43 +660,3 @@ func (s *WorkloadIdentityAPIService) ValidateJWTSVID( func (s *WorkloadIdentityAPIService) String() string { return fmt.Sprintf("%s:%s", config.WorkloadIdentityAPIServiceType, s.cfg.Listen) } - -func issueJWTWorkloadIdentity( - ctx context.Context, - clt *authclient.Client, - workloadIdentity config.WorkloadIdentitySelector, - audiences []string, - ttl time.Duration, - attest *workloadidentityv1pb.WorkloadAttrs, -) ([]*workloadidentityv1pb.Credential, error) { - ctx, span := tracer.Start( - ctx, - "issueJWTWorkloadIdentity", - ) - defer span.End() - - if len(audiences) == 0 { - return nil, nil - } - - // When using the "name" based selector, we either get a single WIC back, - // or an error. We don't need to worry about selecting the right one. - res, err := clt.WorkloadIdentityIssuanceClient().IssueWorkloadIdentity(ctx, - &workloadidentityv1pb.IssueWorkloadIdentityRequest{ - Name: workloadIdentity.Name, - Credential: &workloadidentityv1pb.IssueWorkloadIdentityRequest_JwtSvidParams{ - JwtSvidParams: &workloadidentityv1pb.JWTSVIDParams{ - Audiences: audiences, - }, - }, - RequestedTtl: durationpb.New(ttl), - WorkloadAttrs: attest, - }, - ) - if err != nil { - return nil, trace.Wrap(err) - } - // TODO: Log intimate details of the issued credential - - return []*workloadidentityv1pb.Credential{res.Credential}, nil -} diff --git a/lib/tbot/workloadidentity/issue.go b/lib/tbot/workloadidentity/issue.go index 921eda590e1f3..5c3e46116b1b4 100644 --- a/lib/tbot/workloadidentity/issue.go +++ b/lib/tbot/workloadidentity/issue.go @@ -35,12 +35,27 @@ import ( // WorkloadIdentityLogValue returns a slog.Value for a given // *workloadidentityv1pb.Credential func WorkloadIdentityLogValue(credential *workloadidentityv1pb.Credential) slog.Value { - return slog.GroupValue( + attrs := []slog.Attr{ slog.String("name", credential.GetWorkloadIdentityName()), slog.String("revision", credential.GetWorkloadIdentityRevision()), slog.String("spiffe_id", credential.GetSpiffeId()), slog.String("serial_number", credential.GetX509Svid().GetSerialNumber()), - ) + } + switch v := credential.GetCredential().(type) { + case *workloadidentityv1pb.Credential_X509Svid: + attrs = append( + attrs, + slog.String("type", "x509"), + slog.String("serial_number", v.X509Svid.GetSerialNumber()), + ) + case *workloadidentityv1pb.Credential_JwtSvid: + attrs = append( + attrs, + slog.String("type", "jwt"), + slog.String("jti", v.JwtSvid.GetJti()), + ) + } + return slog.GroupValue(attrs...) } // WorkloadIdentitiesLogValue returns []slog.Value for a slice of @@ -54,7 +69,7 @@ func WorkloadIdentitiesLogValue(credentials []*workloadidentityv1pb.Credential) } // IssueX509WorkloadIdentity uses a given client and selector to issue a single -// or multiple X509 workload identity credentials. +// or multiple X509-SVID workload identity credentials. func IssueX509WorkloadIdentity( ctx context.Context, log *slog.Logger, @@ -65,7 +80,7 @@ func IssueX509WorkloadIdentity( ) ([]*workloadidentityv1pb.Credential, crypto.Signer, error) { ctx, span := tracer.Start( ctx, - "issueX509WorkloadIdentity", + "IssueX509WorkloadIdentity", ) defer span.End() privateKey, err := cryptosuites.GenerateKey(ctx, @@ -110,13 +125,7 @@ func IssueX509WorkloadIdentity( ) return []*workloadidentityv1pb.Credential{res.Credential}, privateKey, nil case len(workloadIdentity.Labels) > 0: - labelSelectors := make([]*workloadidentityv1pb.LabelSelector, 0, len(workloadIdentity.Labels)) - for k, v := range workloadIdentity.Labels { - labelSelectors = append(labelSelectors, &workloadidentityv1pb.LabelSelector{ - Key: k, - Values: v, - }) - } + labelSelectors := labelsToSelectors(workloadIdentity.Labels) log.DebugContext( ctx, "Requesting issuance of X509 workload identity credentials using labels", @@ -147,3 +156,98 @@ func IssueX509WorkloadIdentity( return nil, nil, trace.BadParameter("no valid selector configured") } } + +func labelsToSelectors(in map[string][]string) []*workloadidentityv1pb.LabelSelector { + selectors := make([]*workloadidentityv1pb.LabelSelector, 0, len(in)) + for k, v := range in { + selectors = append(selectors, &workloadidentityv1pb.LabelSelector{ + Key: k, + Values: v, + }) + } + return selectors +} + +// IssueJWTWorkloadIdentity uses a given client and selector to issue a single +// or multiple JWT-SVID workload identity credentials. +func IssueJWTWorkloadIdentity( + ctx context.Context, + log *slog.Logger, + clt *authclient.Client, + workloadIdentity config.WorkloadIdentitySelector, + audiences []string, + ttl time.Duration, + attest *workloadidentityv1pb.WorkloadAttrs, +) ([]*workloadidentityv1pb.Credential, error) { + ctx, span := tracer.Start( + ctx, + "IssueJWTWorkloadIdentity", + ) + defer span.End() + + if len(audiences) == 0 { + return nil, trace.BadParameter("no audiences provided") + } + + switch { + case workloadIdentity.Name != "": + log.DebugContext( + ctx, + "Requesting issuance of JWT workload identity credential using name of WorkloadIdentity resource", + "name", workloadIdentity.Name, + ) + // When using the "name" based selector, we either get a single WIC back, + // or an error. We don't need to worry about selecting the right one. + res, err := clt.WorkloadIdentityIssuanceClient().IssueWorkloadIdentity(ctx, + &workloadidentityv1pb.IssueWorkloadIdentityRequest{ + Name: workloadIdentity.Name, + Credential: &workloadidentityv1pb.IssueWorkloadIdentityRequest_JwtSvidParams{ + JwtSvidParams: &workloadidentityv1pb.JWTSVIDParams{ + Audiences: audiences, + }, + }, + RequestedTtl: durationpb.New(ttl), + WorkloadAttrs: attest, + }, + ) + if err != nil { + return nil, trace.Wrap(err) + } + log.DebugContext( + ctx, + "Received JWT workload identity credential", + "credential", WorkloadIdentityLogValue(res.Credential), + ) + return []*workloadidentityv1pb.Credential{res.Credential}, nil + case len(workloadIdentity.Labels) > 0: + labelSelectors := labelsToSelectors(workloadIdentity.Labels) + log.DebugContext( + ctx, + "Requesting issuance of JWT workload identity credentials using labels", + "labels", labelSelectors, + ) + res, err := clt.WorkloadIdentityIssuanceClient().IssueWorkloadIdentities(ctx, + &workloadidentityv1pb.IssueWorkloadIdentitiesRequest{ + LabelSelectors: labelSelectors, + Credential: &workloadidentityv1pb.IssueWorkloadIdentitiesRequest_JwtSvidParams{ + JwtSvidParams: &workloadidentityv1pb.JWTSVIDParams{ + Audiences: audiences, + }, + }, + RequestedTtl: durationpb.New(ttl), + WorkloadAttrs: attest, + }, + ) + if err != nil { + return nil, trace.Wrap(err) + } + log.DebugContext( + ctx, + "Received JWT workload identity credentials", + "credentials", WorkloadIdentitiesLogValue(res.Credentials), + ) + return res.Credentials, nil + default: + return nil, trace.BadParameter("no valid selector configured") + } +} From 2efa57c52d6b48c64616d6b5af160125809d836a Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Fri, 10 Jan 2025 09:41:24 +0000 Subject: [PATCH 05/10] Add framework of test --- .../service_workload_identity_api_test.go | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 lib/tbot/service_workload_identity_api_test.go diff --git a/lib/tbot/service_workload_identity_api_test.go b/lib/tbot/service_workload_identity_api_test.go new file mode 100644 index 0000000000000..ba373a7256d97 --- /dev/null +++ b/lib/tbot/service_workload_identity_api_test.go @@ -0,0 +1,147 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package tbot + +import ( + "context" + "net/url" + "path/filepath" + "sync" + "testing" + + "github.com/spiffe/go-spiffe/v2/workloadapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/api/types" + apiutils "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/lib/auth/machineid/workloadidentityv1/experiment" + "github.com/gravitational/teleport/lib/tbot/config" + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/tool/teleport/testenv" +) + +func TestBotWorkloadIdentityAPI(t *testing.T) { + experimentStatus := experiment.Enabled() + defer experiment.SetEnabled(experimentStatus) + experiment.SetEnabled(true) + + ctx := context.Background() + log := utils.NewSlogLoggerForTests() + + process := testenv.MakeTestServer(t, defaultTestServerOpts(t, log)) + rootClient := testenv.MakeDefaultAuthClient(t, process) + + role, err := types.NewRole("issue-foo", types.RoleSpecV6{ + Allow: types.RoleConditions{ + WorkloadIdentityLabels: map[string]apiutils.Strings{ + "foo": []string{"bar"}, + }, + Rules: []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentity}, + Verbs: []string{types.VerbRead, types.VerbList}, + }, + }, + }, + }) + require.NoError(t, err) + role, err = rootClient.UpsertRole(ctx, role) + require.NoError(t, err) + + workloadIdentity := &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "foo-bar-bizz", + Labels: map[string]string{ + "foo": "bar", + }, + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/valid/{{ user.bot_name }}/{{ workload.unix.pid }}", + }, + }, + } + workloadIdentity, err = rootClient.WorkloadIdentityResourceServiceClient(). + CreateWorkloadIdentity(ctx, &workloadidentityv1pb.CreateWorkloadIdentityRequest{ + WorkloadIdentity: workloadIdentity, + }) + require.NoError(t, err) + + tmpDir := t.TempDir() + listenAddr := url.URL{ + Scheme: "unix", + Path: filepath.Join(tmpDir, "workload.sock"), + } + onboarding, _ := makeBot(t, rootClient, "api", role.GetName()) + botConfig := defaultBotConfig(t, process, onboarding, config.ServiceConfigs{ + &config.WorkloadIdentityAPIService{ + WorkloadIdentity: config.WorkloadIdentitySelector{ + Name: workloadIdentity.GetMetadata().GetName(), + }, + Listen: listenAddr.String(), + }, + }, defaultBotConfigOpts{ + useAuthServer: true, + insecure: true, + }) + botConfig.Oneshot = false + b := New(botConfig, log) + + // Spin up goroutine for bot to run in + botCtx, cancelBot := context.WithCancel(ctx) + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + err := b.Run(botCtx) + assert.NoError(t, err, "bot should not exit with error") + cancelBot() + }() + t.Cleanup(func() { + // Shut down bot and make sure it exits. + cancelBot() + wg.Wait() + }) + + // This has a little flexibility internally in terms of waiting for the + // socket to come up, so we don't need a manual sleep/retry here. + source, err := workloadapi.NewX509Source( + ctx, + workloadapi.WithClientOptions(workloadapi.WithAddr(listenAddr.String())), + ) + require.NoError(t, err) + defer source.Close() + + svid, err := source.GetX509SVID() + require.NoError(t, err) + + require.Equal(t, "", svid.ID.String()) + // Check issued by bundle etc + + // NewX509Source only invokes the FetchX509SVID RPC. So we also need to + // invoke the FetchX509Bundles RPC. + _, err = workloadapi.FetchX509Bundles(ctx, workloadapi.WithAddr(listenAddr.String())) + + // Then check JWT + // And check JWT issuers + // And check JWT validation +} From d79274466fe4ae273b49056195357c61a43802dc Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Tue, 14 Jan 2025 13:23:26 +0000 Subject: [PATCH 06/10] Rename label selectors for consistency --- lib/tbot/cli/start_workload_identity_api.go | 32 +++++++++---------- .../cli/start_workload_identity_api_test.go | 8 ++--- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/lib/tbot/cli/start_workload_identity_api.go b/lib/tbot/cli/start_workload_identity_api.go index 34df4ebc9e48a..d4af54152481b 100644 --- a/lib/tbot/cli/start_workload_identity_api.go +++ b/lib/tbot/cli/start_workload_identity_api.go @@ -36,12 +36,12 @@ type WorkloadIdentityAPICommand struct { // Listen configures where the workload identity API should listen. This // should be prefixed with a scheme e.g unix:// or tcp://. Listen string - // WorkloadIdentityName is the name of the workload identity to use. - // --workload-identity-name foo - WorkloadIdentityName string - // WorkloadIdentityLabels is the labels of the workload identity to use. - // --workload-identity-labels x=y,z=a - WorkloadIdentityLabels string + // NameSelector is the name of the workload identity to use. + // --name-selector foo + NameSelector string + // LabelSelector is the labels of the workload identity to use. + // --label-selector x=y,z=a + LabelSelector string } // NewWorkloadIdentityAPICommand initializes the command and flags for the @@ -59,15 +59,15 @@ func NewWorkloadIdentityAPICommand(parentCmd *kingpin.CmdClause, action MutatorA c.genericMutatorHandler = newGenericMutatorHandler(cmd, c, action) cmd.Flag( - "workload-identity-name", + "name-selector", "The name of the workload identity to issue", - ).StringVar(&c.WorkloadIdentityName) + ).StringVar(&c.NameSelector) cmd.Flag( - "workload-identity-labels", + "label-selector", "A label-based selector for which workload identities to issue. Multiple labels can be provided using ','.", - ).StringVar(&c.WorkloadIdentityLabels) + ).StringVar(&c.LabelSelector) cmd.Flag( - "listen-addr", + "listen", "The address on which the workload identity API should listen. This should either be prefixed with 'unix://' or 'tcp://'.", ).Required().StringVar(&c.Listen) @@ -84,12 +84,12 @@ func (c *WorkloadIdentityAPICommand) ApplyConfig(cfg *config.BotConfig, l *slog. } switch { - case c.WorkloadIdentityName != "" && c.WorkloadIdentityLabels != "": + case c.NameSelector != "" && c.LabelSelector != "": return trace.BadParameter("workload-identity-name and workload-identity-labels flags are mutually exclusive") - case c.WorkloadIdentityName != "": - svc.WorkloadIdentity.Name = c.WorkloadIdentityName - case c.WorkloadIdentityLabels != "": - labels, err := client.ParseLabelSpec(c.WorkloadIdentityLabels) + case c.NameSelector != "": + svc.WorkloadIdentity.Name = c.NameSelector + case c.LabelSelector != "": + labels, err := client.ParseLabelSpec(c.LabelSelector) if err != nil { return trace.Wrap(err, "parsing --workload-identity-labels") } diff --git a/lib/tbot/cli/start_workload_identity_api_test.go b/lib/tbot/cli/start_workload_identity_api_test.go index b30a0f2fede4d..7fad3ce2d6c83 100644 --- a/lib/tbot/cli/start_workload_identity_api_test.go +++ b/lib/tbot/cli/start_workload_identity_api_test.go @@ -34,8 +34,8 @@ func TestNewWorkloadIdentityAPICommand(t *testing.T) { "--token=foo", "--join-method=github", "--proxy-server=example.com:443", - "--listen-addr=tcp://0.0.0.0:8080", - "--workload-identity-labels=*=*,foo=bar", + "--listen=tcp://0.0.0.0:8080", + "--label-selector=*=*,foo=bar", }, assertConfig: func(t *testing.T, cfg *config.BotConfig) { require.Len(t, cfg.Services, 1) @@ -58,8 +58,8 @@ func TestNewWorkloadIdentityAPICommand(t *testing.T) { "--token=foo", "--join-method=github", "--proxy-server=example.com:443", - "--listen-addr=unix:///opt/workload.sock", - "--workload-identity-name=jim", + "--listen=unix:///opt/workload.sock", + "--name-selector=jim", }, assertConfig: func(t *testing.T, cfg *config.BotConfig) { require.Len(t, cfg.Services, 1) From 1b5c1f170b7e995fcde86ea3b426c474c01a1edd Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Tue, 14 Jan 2025 13:33:24 +0000 Subject: [PATCH 07/10] Rename for consistency --- lib/tbot/cli/start_workload_identity_api.go | 6 +++--- lib/tbot/cli/start_workload_identity_api_test.go | 4 ++-- lib/tbot/config/config_test.go | 2 +- lib/tbot/config/service_workload_identity_api.go | 8 ++++---- .../config/service_workload_identity_api_test.go | 16 ++++++++-------- .../TestBotConfig_YAML/standard_config.golden | 2 +- .../full.golden | 2 +- .../minimal.golden | 2 +- lib/tbot/service_workload_identity_api.go | 4 ++-- lib/tbot/service_workload_identity_api_test.go | 2 +- 10 files changed, 24 insertions(+), 24 deletions(-) diff --git a/lib/tbot/cli/start_workload_identity_api.go b/lib/tbot/cli/start_workload_identity_api.go index d4af54152481b..221a53942a2ad 100644 --- a/lib/tbot/cli/start_workload_identity_api.go +++ b/lib/tbot/cli/start_workload_identity_api.go @@ -87,15 +87,15 @@ func (c *WorkloadIdentityAPICommand) ApplyConfig(cfg *config.BotConfig, l *slog. case c.NameSelector != "" && c.LabelSelector != "": return trace.BadParameter("workload-identity-name and workload-identity-labels flags are mutually exclusive") case c.NameSelector != "": - svc.WorkloadIdentity.Name = c.NameSelector + svc.Selector.Name = c.NameSelector case c.LabelSelector != "": labels, err := client.ParseLabelSpec(c.LabelSelector) if err != nil { return trace.Wrap(err, "parsing --workload-identity-labels") } - svc.WorkloadIdentity.Labels = map[string][]string{} + svc.Selector.Labels = map[string][]string{} for k, v := range labels { - svc.WorkloadIdentity.Labels[k] = []string{v} + svc.Selector.Labels[k] = []string{v} } default: return trace.BadParameter("workload-identity-name or workload-identity-labels must be specified") diff --git a/lib/tbot/cli/start_workload_identity_api_test.go b/lib/tbot/cli/start_workload_identity_api_test.go index 7fad3ce2d6c83..eba95ca23e470 100644 --- a/lib/tbot/cli/start_workload_identity_api_test.go +++ b/lib/tbot/cli/start_workload_identity_api_test.go @@ -47,7 +47,7 @@ func TestNewWorkloadIdentityAPICommand(t *testing.T) { require.Equal(t, map[string][]string{ "*": {"*"}, "foo": {"bar"}, - }, wis.WorkloadIdentity.Labels) + }, wis.Selector.Labels) }, }, { @@ -68,7 +68,7 @@ func TestNewWorkloadIdentityAPICommand(t *testing.T) { wis, ok := svc.(*config.WorkloadIdentityAPIService) require.True(t, ok) require.Equal(t, "unix:///opt/workload.sock", wis.Listen) - require.Equal(t, "jim", wis.WorkloadIdentity.Name) + require.Equal(t, "jim", wis.Selector.Name) }, }, }) diff --git a/lib/tbot/config/config_test.go b/lib/tbot/config/config_test.go index 0696862fdacb6..d19abe08599d9 100644 --- a/lib/tbot/config/config_test.go +++ b/lib/tbot/config/config_test.go @@ -274,7 +274,7 @@ func TestBotConfig_YAML(t *testing.T) { }, &WorkloadIdentityAPIService{ Listen: "tcp://127.0.0.1:123", - WorkloadIdentity: WorkloadIdentitySelector{ + Selector: WorkloadIdentitySelector{ Name: "my-workload-identity", }, }, diff --git a/lib/tbot/config/service_workload_identity_api.go b/lib/tbot/config/service_workload_identity_api.go index cabdd6b19c911..2c9d056868d25 100644 --- a/lib/tbot/config/service_workload_identity_api.go +++ b/lib/tbot/config/service_workload_identity_api.go @@ -37,9 +37,9 @@ type WorkloadIdentityAPIService struct { Listen string `yaml:"listen"` // Attestors is the configuration for the workload attestation process. Attestors workloadattest.Config `yaml:"attestors"` - // WorkloadIdentity is the selector for the WorkloadIdentity resource that + // Selector is the selector for the WorkloadIdentity resource that // will be used to issue WICs. - WorkloadIdentity WorkloadIdentitySelector `yaml:"workload_identity"` + Selector WorkloadIdentitySelector `yaml:"selector"` } // CheckAndSetDefaults checks the SPIFFESVIDOutput values and sets any defaults. @@ -50,8 +50,8 @@ func (o *WorkloadIdentityAPIService) CheckAndSetDefaults() error { if err := o.Attestors.CheckAndSetDefaults(); err != nil { return trace.Wrap(err, "validating attestor") } - if err := o.WorkloadIdentity.CheckAndSetDefaults(); err != nil { - return trace.Wrap(err, "validating workload_identity") + if err := o.Selector.CheckAndSetDefaults(); err != nil { + return trace.Wrap(err, "validating selector") } return nil } diff --git a/lib/tbot/config/service_workload_identity_api_test.go b/lib/tbot/config/service_workload_identity_api_test.go index da2b6330229ee..b1d6645dd9e57 100644 --- a/lib/tbot/config/service_workload_identity_api_test.go +++ b/lib/tbot/config/service_workload_identity_api_test.go @@ -42,7 +42,7 @@ func TestWorkloadIdentityAPIService_YAML(t *testing.T) { }, }, }, - WorkloadIdentity: WorkloadIdentitySelector{ + Selector: WorkloadIdentitySelector{ Name: "my-workload-identity", }, }, @@ -51,7 +51,7 @@ func TestWorkloadIdentityAPIService_YAML(t *testing.T) { name: "minimal", in: WorkloadIdentityAPIService{ Listen: "tcp://0.0.0.0:4040", - WorkloadIdentity: WorkloadIdentitySelector{ + Selector: WorkloadIdentitySelector{ Name: "my-workload-identity", }, }, @@ -68,7 +68,7 @@ func TestWorkloadIdentityAPIService_CheckAndSetDefaults(t *testing.T) { name: "valid", in: func() *WorkloadIdentityAPIService { return &WorkloadIdentityAPIService{ - WorkloadIdentity: WorkloadIdentitySelector{ + Selector: WorkloadIdentitySelector{ Name: "my-workload-identity", }, Listen: "tcp://0.0.0.0:4040", @@ -79,7 +79,7 @@ func TestWorkloadIdentityAPIService_CheckAndSetDefaults(t *testing.T) { name: "valid with labels", in: func() *WorkloadIdentityAPIService { return &WorkloadIdentityAPIService{ - WorkloadIdentity: WorkloadIdentitySelector{ + Selector: WorkloadIdentitySelector{ Labels: map[string][]string{ "key": {"value"}, }, @@ -92,8 +92,8 @@ func TestWorkloadIdentityAPIService_CheckAndSetDefaults(t *testing.T) { name: "missing selectors", in: func() *WorkloadIdentityAPIService { return &WorkloadIdentityAPIService{ - WorkloadIdentity: WorkloadIdentitySelector{}, - Listen: "tcp://0.0.0.0:4040", + Selector: WorkloadIdentitySelector{}, + Listen: "tcp://0.0.0.0:4040", } }, wantErr: "one of ['name', 'labels'] must be set", @@ -102,7 +102,7 @@ func TestWorkloadIdentityAPIService_CheckAndSetDefaults(t *testing.T) { name: "too many selectors", in: func() *WorkloadIdentityAPIService { return &WorkloadIdentityAPIService{ - WorkloadIdentity: WorkloadIdentitySelector{ + Selector: WorkloadIdentitySelector{ Name: "my-workload-identity", Labels: map[string][]string{ "key": {"value"}, @@ -117,7 +117,7 @@ func TestWorkloadIdentityAPIService_CheckAndSetDefaults(t *testing.T) { name: "missing listen", in: func() *WorkloadIdentityAPIService { return &WorkloadIdentityAPIService{ - WorkloadIdentity: WorkloadIdentitySelector{ + Selector: WorkloadIdentitySelector{ Name: "my-workload-identity", }, } diff --git a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden index 0ef46a5261d1d..302209284ade6 100644 --- a/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden +++ b/lib/tbot/config/testdata/TestBotConfig_YAML/standard_config.golden @@ -66,7 +66,7 @@ services: attestors: kubernetes: enabled: false - workload_identity: + selector: name: my-workload-identity debug: true auth_server: example.teleport.sh:443 diff --git a/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/full.golden b/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/full.golden index a46e2c820806e..3727e43be0576 100644 --- a/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/full.golden +++ b/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/full.golden @@ -9,5 +9,5 @@ attestors: ca_path: /path/to/ca.pem skip_verify: true anonymous: true -workload_identity: +selector: name: my-workload-identity diff --git a/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/minimal.golden b/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/minimal.golden index dacfe222089fc..062942dc4da95 100644 --- a/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/minimal.golden +++ b/lib/tbot/config/testdata/TestWorkloadIdentityAPIService_YAML/minimal.golden @@ -3,5 +3,5 @@ listen: tcp://0.0.0.0:4040 attestors: kubernetes: enabled: false -workload_identity: +selector: name: my-workload-identity diff --git a/lib/tbot/service_workload_identity_api.go b/lib/tbot/service_workload_identity_api.go index 132b0a60b021c..e9159dfe9cf73 100644 --- a/lib/tbot/service_workload_identity_api.go +++ b/lib/tbot/service_workload_identity_api.go @@ -407,7 +407,7 @@ func (s *WorkloadIdentityAPIService) fetchX509SVIDs( ctx, log, s.client, - s.cfg.WorkloadIdentity, + s.cfg.Selector, s.botCfg.CertificateTTL, attest, ) @@ -488,7 +488,7 @@ func (s *WorkloadIdentityAPIService) FetchJWTSVID( ctx, log, s.client, - s.cfg.WorkloadIdentity, + s.cfg.Selector, req.Audience, s.botCfg.CertificateTTL, attr, diff --git a/lib/tbot/service_workload_identity_api_test.go b/lib/tbot/service_workload_identity_api_test.go index ba373a7256d97..f27f4727a4206 100644 --- a/lib/tbot/service_workload_identity_api_test.go +++ b/lib/tbot/service_workload_identity_api_test.go @@ -94,7 +94,7 @@ func TestBotWorkloadIdentityAPI(t *testing.T) { onboarding, _ := makeBot(t, rootClient, "api", role.GetName()) botConfig := defaultBotConfig(t, process, onboarding, config.ServiceConfigs{ &config.WorkloadIdentityAPIService{ - WorkloadIdentity: config.WorkloadIdentitySelector{ + Selector: config.WorkloadIdentitySelector{ Name: workloadIdentity.GetMetadata().GetName(), }, Listen: listenAddr.String(), From b0fd962d926c995df550714aa60d49cbe4b8fb12 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Tue, 14 Jan 2025 13:57:35 +0000 Subject: [PATCH 08/10] Add more assertions to TestBotWorkloadIdentityAPI --- .../service_workload_identity_api_test.go | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/lib/tbot/service_workload_identity_api_test.go b/lib/tbot/service_workload_identity_api_test.go index f27f4727a4206..6570b343f5686 100644 --- a/lib/tbot/service_workload_identity_api_test.go +++ b/lib/tbot/service_workload_identity_api_test.go @@ -18,11 +18,15 @@ package tbot import ( "context" + "fmt" "net/url" + "os" "path/filepath" "sync" "testing" + "github.com/spiffe/go-spiffe/v2/svid/jwtsvid" + "github.com/spiffe/go-spiffe/v2/svid/x509svid" "github.com/spiffe/go-spiffe/v2/workloadapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -124,24 +128,45 @@ func TestBotWorkloadIdentityAPI(t *testing.T) { // This has a little flexibility internally in terms of waiting for the // socket to come up, so we don't need a manual sleep/retry here. + client, err := workloadapi.New(ctx, workloadapi.WithAddr(listenAddr.String())) + require.NoError(t, err) + source, err := workloadapi.NewX509Source( ctx, - workloadapi.WithClientOptions(workloadapi.WithAddr(listenAddr.String())), + workloadapi.WithClient(client), ) require.NoError(t, err) defer source.Close() + // Test FetchX509SVID svid, err := source.GetX509SVID() require.NoError(t, err) - require.Equal(t, "", svid.ID.String()) - // Check issued by bundle etc + expectedSPIFFEID := fmt.Sprintf("spiffe://root/valid/api/%d", os.Getpid()) + require.Equal(t, expectedSPIFFEID, svid.ID.String()) + require.Equal(t, expectedSPIFFEID, svid.Certificates[0].URIs[0].String()) + _, _, err = x509svid.Verify(svid.Certificates, source) + require.NoError(t, err) - // NewX509Source only invokes the FetchX509SVID RPC. So we also need to - // invoke the FetchX509Bundles RPC. - _, err = workloadapi.FetchX509Bundles(ctx, workloadapi.WithAddr(listenAddr.String())) + // Test FetchX509Bundles + set, err := client.FetchX509Bundles(ctx) + require.NoError(t, err) + _, _, err = x509svid.Verify(svid.Certificates, set) + require.NoError(t, err) - // Then check JWT - // And check JWT issuers - // And check JWT validation + // Test FetchJWTSVID + jwtSVID, err := client.FetchJWTSVID(ctx, jwtsvid.Params{ + Audience: "example.com", + }) + require.NoError(t, err) + + // Check against ValidateJWTSVID + parsed, err := client.ValidateJWTSVID(ctx, jwtSVID.Marshal(), "example.com") + require.NoError(t, err) + require.Equal(t, expectedSPIFFEID, parsed.ID.String()) + // Perform local validation with bundles from FetchJWTBundles + jwtBundles, err := client.FetchJWTBundles(ctx) + require.NoError(t, err) + _, err = jwtsvid.ParseAndValidate(jwtSVID.Marshal(), jwtBundles, []string{"example.com"}) + require.NoError(t, err) } From b426d09e07c3590fbe6b00d7f4458a74a01044b3 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Tue, 14 Jan 2025 18:12:40 +0000 Subject: [PATCH 09/10] Add to command registry --- tool/tbot/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tool/tbot/main.go b/tool/tbot/main.go index 33281662d0a29..fac8d35747ab6 100644 --- a/tool/tbot/main.go +++ b/tool/tbot/main.go @@ -143,6 +143,9 @@ func Run(args []string, stdout io.Writer) error { cli.NewWorkloadIdentityX509Command(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart), cli.NewWorkloadIdentityX509Command(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure), + + cli.NewWorkloadIdentityAPICommand(startCmd, buildConfigAndStart(ctx, globalCfg), cli.CommandModeStart), + cli.NewWorkloadIdentityAPICommand(configureCmd, buildConfigAndConfigure(ctx, globalCfg, &configureOutPath, stdout), cli.CommandModeConfigure), ) // Initialize legacy-style commands. These are simple enough to not really From 1db7675d73330ed33248cdf8b0b8d60cbf00ed62 Mon Sep 17 00:00:00 2001 From: Noah Stride Date: Fri, 17 Jan 2025 09:30:14 +0000 Subject: [PATCH 10/10] Fix CLI flags --- lib/tbot/cli/start_workload_identity_api.go | 6 +++--- lib/tbot/cli/start_workload_identity_x509.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/tbot/cli/start_workload_identity_api.go b/lib/tbot/cli/start_workload_identity_api.go index 221a53942a2ad..9dc52024f106e 100644 --- a/lib/tbot/cli/start_workload_identity_api.go +++ b/lib/tbot/cli/start_workload_identity_api.go @@ -85,20 +85,20 @@ func (c *WorkloadIdentityAPICommand) ApplyConfig(cfg *config.BotConfig, l *slog. switch { case c.NameSelector != "" && c.LabelSelector != "": - return trace.BadParameter("workload-identity-name and workload-identity-labels flags are mutually exclusive") + return trace.BadParameter("name-selector and label-selector flags are mutually exclusive") case c.NameSelector != "": svc.Selector.Name = c.NameSelector case c.LabelSelector != "": labels, err := client.ParseLabelSpec(c.LabelSelector) if err != nil { - return trace.Wrap(err, "parsing --workload-identity-labels") + return trace.Wrap(err, "parsing label-selector") } svc.Selector.Labels = map[string][]string{} for k, v := range labels { svc.Selector.Labels[k] = []string{v} } default: - return trace.BadParameter("workload-identity-name or workload-identity-labels must be specified") + return trace.BadParameter("name-selector and label-selector must be specified") } cfg.Services = append(cfg.Services, svc) diff --git a/lib/tbot/cli/start_workload_identity_x509.go b/lib/tbot/cli/start_workload_identity_x509.go index bd1c0a5aa0890..92c322acdc312 100644 --- a/lib/tbot/cli/start_workload_identity_x509.go +++ b/lib/tbot/cli/start_workload_identity_x509.go @@ -89,20 +89,20 @@ func (c *WorkloadIdentityX509Command) ApplyConfig(cfg *config.BotConfig, l *slog switch { case c.NameSelector != "" && c.LabelSelector != "": - return trace.BadParameter("workload-identity-name and workload-identity-labels flags are mutually exclusive") + return trace.BadParameter("name-selector and label-selector flags are mutually exclusive") case c.NameSelector != "": svc.Selector.Name = c.NameSelector case c.LabelSelector != "": labels, err := client.ParseLabelSpec(c.LabelSelector) if err != nil { - return trace.Wrap(err, "parsing --workload-identity-labels") + return trace.Wrap(err, "parsing --label-selector") } svc.Selector.Labels = map[string][]string{} for k, v := range labels { svc.Selector.Labels[k] = []string{v} } default: - return trace.BadParameter("workload-identity-name or workload-identity-labels must be specified") + return trace.BadParameter("name-selector or label-selector must be specified") } cfg.Services = append(cfg.Services, svc)