diff --git a/api/client/client.go b/api/client/client.go
index 0036f57058b6e..11f33a87f0abc 100644
--- a/api/client/client.go
+++ b/api/client/client.go
@@ -858,6 +858,10 @@ func (c *Client) BotInstanceServiceClient() machineidv1pb.BotInstanceServiceClie
return machineidv1pb.NewBotInstanceServiceClient(c.conn)
}
+func (c *Client) SPIFFEFederationServiceClient() machineidv1pb.SPIFFEFederationServiceClient {
+ return machineidv1pb.NewSPIFFEFederationServiceClient(c.conn)
+}
+
// PresenceServiceClient returns an unadorned client for the presence service.
func (c *Client) PresenceServiceClient() presencepb.PresenceServiceClient {
return presencepb.NewPresenceServiceClient(c.conn)
diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go
index 368bf60215f85..48b26e56271bc 100644
--- a/lib/auth/grpcserver.go
+++ b/lib/auth/grpcserver.go
@@ -5192,6 +5192,18 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) {
}
machineidv1pb.RegisterWorkloadIdentityServiceServer(server, workloadIdentityService)
+ spiffeFederationService, err := machineidv1.NewSPIFFEFederationService(machineidv1.SPIFFEFederationServiceConfig{
+ Authorizer: cfg.Authorizer,
+ Backend: cfg.AuthServer.Services.SPIFFEFederations,
+ Cache: cfg.AuthServer.Cache,
+ Clock: cfg.AuthServer.GetClock(),
+ Emitter: cfg.Emitter,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err, "creating SPIFFE federation service")
+ }
+ machineidv1pb.RegisterSPIFFEFederationServiceServer(server, spiffeFederationService)
+
dbObjectImportRuleService, err := dbobjectimportrulev1.NewDatabaseObjectImportRuleService(dbobjectimportrulev1.DatabaseObjectImportRuleServiceConfig{
Authorizer: cfg.Authorizer,
Backend: cfg.AuthServer.Services,
diff --git a/lib/auth/machineid/machineidv1/machineidv1_test.go b/lib/auth/machineid/machineidv1/machineidv1_test.go
index 6e66704c674ee..285807f402d97 100644
--- a/lib/auth/machineid/machineidv1/machineidv1_test.go
+++ b/lib/auth/machineid/machineidv1/machineidv1_test.go
@@ -42,6 +42,7 @@ import (
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/lib/auth"
"github.com/gravitational/teleport/lib/auth/machineid/machineidv1"
+ "github.com/gravitational/teleport/lib/events/eventstest"
"github.com/gravitational/teleport/lib/modules"
)
@@ -66,7 +67,7 @@ func TestBotResourceName(t *testing.T) {
// TestCreateBot is an integration test that uses a real gRPC client/server.
func TestCreateBot(t *testing.T) {
t.Parallel()
- srv := newTestTLSServer(t)
+ srv, _ := newTestTLSServer(t)
ctx := context.Background()
botCreator, _, err := auth.CreateUserAndRole(
@@ -477,7 +478,7 @@ func TestCreateBot(t *testing.T) {
// TestUpdateBot is an integration test that uses a real gRPC client/server.
func TestUpdateBot(t *testing.T) {
t.Parallel()
- srv := newTestTLSServer(t)
+ srv, _ := newTestTLSServer(t)
ctx := context.Background()
botUpdaterUser, _, err := auth.CreateUserAndRole(srv.Auth(), "bot-updater", []string{}, []types.Rule{
@@ -840,7 +841,7 @@ func TestUpdateBot(t *testing.T) {
// TestUpsertBot is an integration test that uses a real gRPC client/server.
func TestUpsertBot(t *testing.T) {
t.Parallel()
- srv := newTestTLSServer(t)
+ srv, _ := newTestTLSServer(t)
ctx := context.Background()
botCreator, _, err := auth.CreateUserAndRole(srv.Auth(), "bot-creator", []string{}, []types.Rule{
@@ -1268,7 +1269,7 @@ func TestUpsertBot(t *testing.T) {
// TestGetBot is an integration test that uses a real gRPC client/server.
func TestGetBot(t *testing.T) {
t.Parallel()
- srv := newTestTLSServer(t)
+ srv, _ := newTestTLSServer(t)
ctx := context.Background()
botGetterUser, _, err := auth.CreateUserAndRole(
@@ -1379,7 +1380,7 @@ func TestGetBot(t *testing.T) {
// TestListBots is an integration test that uses a real gRPC client/server.
func TestListBots(t *testing.T) {
t.Parallel()
- srv := newTestTLSServer(t)
+ srv, _ := newTestTLSServer(t)
ctx := context.Background()
botListerUser, _, err := auth.CreateUserAndRole(
@@ -1490,7 +1491,7 @@ func TestListBots(t *testing.T) {
// TestDeleteBot is an integration test that uses a real gRPC client/server.
func TestDeleteBot(t *testing.T) {
t.Parallel()
- srv := newTestTLSServer(t)
+ srv, _ := newTestTLSServer(t)
ctx := context.Background()
botDeleterUser, _, err := auth.CreateUserAndRole(
@@ -1630,14 +1631,17 @@ func TestDeleteBot(t *testing.T) {
}
}
-func newTestTLSServer(t testing.TB) *auth.TestTLSServer {
+func newTestTLSServer(t testing.TB) (*auth.TestTLSServer, *eventstest.MockRecorderEmitter) {
as, err := auth.NewTestAuthServer(auth.TestAuthServerConfig{
Dir: t.TempDir(),
Clock: clockwork.NewFakeClockAt(time.Now().Round(time.Second).UTC()),
})
require.NoError(t, err)
- srv, err := as.NewTestTLSServer()
+ emitter := &eventstest.MockRecorderEmitter{}
+ srv, err := as.NewTestTLSServer(func(config *auth.TestTLSServerConfig) {
+ config.APIConfig.Emitter = emitter
+ })
require.NoError(t, err)
t.Cleanup(func() {
@@ -1648,5 +1652,5 @@ func newTestTLSServer(t testing.TB) *auth.TestTLSServer {
require.NoError(t, err)
})
- return srv
+ return srv, emitter
}
diff --git a/lib/auth/machineid/machineidv1/spiffe_federation_service.go b/lib/auth/machineid/machineidv1/spiffe_federation_service.go
new file mode 100644
index 0000000000000..62d4290c922b8
--- /dev/null
+++ b/lib/auth/machineid/machineidv1/spiffe_federation_service.go
@@ -0,0 +1,252 @@
+// Teleport
+// Copyright (C) 2024 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 machineidv1
+
+import (
+ "context"
+ "log/slog"
+
+ "github.com/gravitational/trace"
+ "github.com/jonboulle/clockwork"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/types/known/emptypb"
+
+ "github.com/gravitational/teleport"
+ machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
+ "github.com/gravitational/teleport/api/types"
+ apievents "github.com/gravitational/teleport/api/types/events"
+ "github.com/gravitational/teleport/lib/authz"
+ "github.com/gravitational/teleport/lib/events"
+)
+
+type spiffeFederationReader interface {
+ ListSPIFFEFederations(ctx context.Context, limit int, token string) ([]*machineidv1.SPIFFEFederation, string, error)
+ GetSPIFFEFederation(ctx context.Context, name string) (*machineidv1.SPIFFEFederation, error)
+}
+
+type spiffeFederationReadWriter interface {
+ spiffeFederationReader
+ CreateSPIFFEFederation(ctx context.Context, federation *machineidv1.SPIFFEFederation) (*machineidv1.SPIFFEFederation, error)
+ DeleteSPIFFEFederation(ctx context.Context, name string) error
+}
+
+// SPIFFEFederationServiceConfig holds configuration options for
+// NewSPIFFEFederationService
+type SPIFFEFederationServiceConfig struct {
+ // Authorizer is the authorizer service which checks access to resources.
+ Authorizer authz.Authorizer
+ // Backend will be used reading and writing the SPIFFE Federation resources.
+ Backend spiffeFederationReadWriter
+ // Cache will be used when reading SPIFFE Federation resources.
+ Cache spiffeFederationReader
+ // Clock is the clock instance to use. Useful for injecting in tests.
+ Clock clockwork.Clock
+ // Emitter is the event emitter to use when emitting audit events.
+ Emitter apievents.Emitter
+ // Logger is the logger instance to use.
+ Logger *slog.Logger
+}
+
+// NewSPIFFEFederationService returns a new instance of the SPIFFEFederationService.
+func NewSPIFFEFederationService(
+ cfg SPIFFEFederationServiceConfig,
+) (*SPIFFEFederationService, error) {
+ switch {
+ case cfg.Backend == nil:
+ return nil, trace.BadParameter("backend service is required")
+ case cfg.Cache == nil:
+ return nil, trace.BadParameter("cache service is required")
+ case cfg.Authorizer == nil:
+ return nil, trace.BadParameter("authorizer is required")
+ case cfg.Emitter == nil:
+ return nil, trace.BadParameter("emitter is required")
+ }
+
+ if cfg.Logger == nil {
+ cfg.Logger = slog.With(teleport.ComponentKey, "spiffe_federation.service")
+ }
+ if cfg.Clock == nil {
+ cfg.Clock = clockwork.NewRealClock()
+ }
+
+ return &SPIFFEFederationService{
+ authorizer: cfg.Authorizer,
+ backend: cfg.Backend,
+ cache: cfg.Cache,
+ clock: cfg.Clock,
+ emitter: cfg.Emitter,
+ logger: cfg.Logger,
+ }, nil
+}
+
+// SPIFFEFederationService is an implementation of
+// teleport.machineid.v1.SPIFFEFederationService
+type SPIFFEFederationService struct {
+ machineidv1.UnimplementedSPIFFEFederationServiceServer
+
+ authorizer authz.Authorizer
+ backend spiffeFederationReadWriter
+ cache spiffeFederationReader
+ clock clockwork.Clock
+ emitter apievents.Emitter
+ logger *slog.Logger
+}
+
+// GetSPIFFEFederation returns a SPIFFE Federation by name.
+// Implements teleport.machineid.v1.SPIFFEFederationService/GetSPIFFEFederation
+func (s *SPIFFEFederationService) GetSPIFFEFederation(
+ ctx context.Context, req *machineidv1.GetSPIFFEFederationRequest,
+) (*machineidv1.SPIFFEFederation, error) {
+ authCtx, err := s.authorizer.Authorize(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := authCtx.CheckAccessToKind(types.KindSPIFFEFederation, types.VerbRead); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if req.Name == "" {
+ return nil, trace.BadParameter("name: must be non-empty")
+ }
+
+ federation, err := s.cache.GetSPIFFEFederation(ctx, req.Name)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return federation, nil
+}
+
+// ListSPIFFEFederations returns a list of SPIFFE Federations. It follows the
+// Google API design guidelines for list pagination.
+// Implements teleport.machineid.v1.SPIFFEFederationService/ListSPIFFEFederations
+func (s *SPIFFEFederationService) ListSPIFFEFederations(
+ ctx context.Context, req *machineidv1.ListSPIFFEFederationsRequest,
+) (*machineidv1.ListSPIFFEFederationsResponse, error) {
+ authCtx, err := s.authorizer.Authorize(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := authCtx.CheckAccessToKind(types.KindSPIFFEFederation, types.VerbRead, types.VerbList); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ federations, nextToken, err := s.cache.ListSPIFFEFederations(
+ ctx,
+ int(req.PageSize),
+ req.PageToken,
+ )
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return &machineidv1.ListSPIFFEFederationsResponse{
+ SpiffeFederations: federations,
+ NextPageToken: nextToken,
+ }, nil
+}
+
+// DeleteSPIFFEFederation deletes a SPIFFE Federation by name.
+// Implements teleport.machineid.v1.SPIFFEFederationService/DeleteSPIFFEFederation
+func (s *SPIFFEFederationService) DeleteSPIFFEFederation(
+ ctx context.Context, req *machineidv1.DeleteSPIFFEFederationRequest,
+) (*emptypb.Empty, error) {
+ authCtx, err := s.authorizer.Authorize(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := authCtx.CheckAccessToKind(types.KindSPIFFEFederation, types.VerbDelete); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := authCtx.AuthorizeAdminAction(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if req.Name == "" {
+ return nil, trace.BadParameter("name: must be non-empty")
+ }
+
+ if err := s.backend.DeleteSPIFFEFederation(ctx, req.Name); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if err := s.emitter.EmitAuditEvent(ctx, &apievents.SPIFFEFederationDelete{
+ Metadata: apievents.Metadata{
+ Code: events.SPIFFEFederationDeleteCode,
+ Type: events.SPIFFEFederationDeleteEvent,
+ },
+ UserMetadata: authz.ClientUserMetadata(ctx),
+ ConnectionMetadata: authz.ConnectionMetadata(ctx),
+ ResourceMetadata: apievents.ResourceMetadata{
+ Name: req.Name,
+ },
+ }); err != nil {
+ s.logger.ErrorContext(
+ ctx, "Failed to emit audit event for deletion of SPIFFEFederation",
+ "error", err,
+ )
+ }
+
+ return &emptypb.Empty{}, nil
+}
+
+// CreateSPIFFEFederation creates a new SPIFFE Federation.
+// Implements teleport.machineid.v1.SPIFFEFederationService/CreateSPIFFEFederation
+func (s *SPIFFEFederationService) CreateSPIFFEFederation(
+ ctx context.Context, req *machineidv1.CreateSPIFFEFederationRequest,
+) (*machineidv1.SPIFFEFederation, error) {
+ authCtx, err := s.authorizer.Authorize(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := authCtx.CheckAccessToKind(types.KindSPIFFEFederation, types.VerbCreate); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if err := authCtx.AuthorizeAdminAction(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if status := req.GetSpiffeFederation().GetStatus(); status != nil {
+ if !proto.Equal(status, &machineidv1.SPIFFEFederationStatus{}) {
+ return nil, trace.BadParameter("status: cannot be set")
+ }
+ }
+
+ created, err := s.backend.CreateSPIFFEFederation(ctx, req.SpiffeFederation)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ if err := s.emitter.EmitAuditEvent(ctx, &apievents.SPIFFEFederationCreate{
+ Metadata: apievents.Metadata{
+ Code: events.SPIFFEFederationCreateCode,
+ Type: events.SPIFFEFederationCreateEvent,
+ },
+ UserMetadata: authz.ClientUserMetadata(ctx),
+ ConnectionMetadata: authz.ConnectionMetadata(ctx),
+ ResourceMetadata: apievents.ResourceMetadata{
+ Name: req.SpiffeFederation.Metadata.Name,
+ },
+ }); err != nil {
+ s.logger.ErrorContext(
+ ctx, "Failed to emit audit event for creation of SPIFFEFederation",
+ "error", err,
+ )
+ }
+
+ return created, nil
+}
diff --git a/lib/auth/machineid/machineidv1/spiffe_federation_service_test.go b/lib/auth/machineid/machineidv1/spiffe_federation_service_test.go
new file mode 100644
index 0000000000000..e69511523011b
--- /dev/null
+++ b/lib/auth/machineid/machineidv1/spiffe_federation_service_test.go
@@ -0,0 +1,573 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 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 machineidv1_test
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/google/go-cmp/cmp/cmpopts"
+ "github.com/gravitational/trace"
+ "github.com/stretchr/testify/require"
+ "google.golang.org/protobuf/proto"
+ "google.golang.org/protobuf/testing/protocmp"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
+ machineidv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/api/types/events"
+ "github.com/gravitational/teleport/lib/auth"
+ libevents "github.com/gravitational/teleport/lib/events"
+)
+
+// TestSPIFFEFederationService_CreateSPIFFEFederation is an integration test
+// that uses a real gRPC client/server.
+func TestSPIFFEFederationService_CreateSPIFFEFederation(t *testing.T) {
+ t.Parallel()
+ srv, mockEmitter := newTestTLSServer(t)
+ ctx := context.Background()
+
+ nothingRole, err := types.NewRole("nothing", types.RoleSpecV6{})
+ require.NoError(t, err)
+ unauthorizedUser, err := auth.CreateUser(
+ ctx,
+ srv.Auth(),
+ "unauthorized",
+ // Nothing role necessary as otherwise authz engine gets confused.
+ nothingRole,
+ )
+ require.NoError(t, err)
+
+ role, err := types.NewRole("federation-creator", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ {
+ Resources: []string{types.KindSPIFFEFederation},
+ Verbs: []string{types.VerbCreate},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ authorizedUser, err := auth.CreateUser(
+ ctx,
+ srv.Auth(),
+ "authorized",
+ // Nothing role necessary as otherwise authz engine gets confused.
+ role,
+ )
+ require.NoError(t, err)
+
+ good := &machineidv1pb.SPIFFEFederation{
+ Kind: types.KindSPIFFEFederation,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: "example.com",
+ },
+ Spec: &machineidv1pb.SPIFFEFederationSpec{
+ BundleSource: &machineidv1pb.SPIFFEFederationBundleSource{
+ HttpsWeb: &machineidv1pb.SPIFFEFederationBundleSourceHTTPSWeb{
+ BundleEndpointUrl: "https://example.com/bundle.json",
+ },
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ user string
+ req *machineidv1pb.CreateSPIFFEFederationRequest
+ requireError require.ErrorAssertionFunc
+ requireSuccess bool
+ requireEvent *events.SPIFFEFederationCreate
+ }{
+ {
+ name: "success",
+ user: authorizedUser.GetName(),
+ req: &machineidv1pb.CreateSPIFFEFederationRequest{
+ SpiffeFederation: good,
+ },
+ requireError: require.NoError,
+ requireSuccess: true,
+ requireEvent: &events.SPIFFEFederationCreate{
+ Metadata: events.Metadata{
+ Type: libevents.SPIFFEFederationCreateEvent,
+ Code: libevents.SPIFFEFederationCreateCode,
+ },
+ ResourceMetadata: events.ResourceMetadata{
+ Name: "example.com",
+ },
+ UserMetadata: events.UserMetadata{
+ User: authorizedUser.GetName(),
+ UserKind: events.UserKind_USER_KIND_HUMAN,
+ },
+ },
+ },
+ {
+ name: "unable to set status",
+ user: authorizedUser.GetName(),
+ req: &machineidv1pb.CreateSPIFFEFederationRequest{
+ SpiffeFederation: func() *machineidv1pb.SPIFFEFederation {
+ fed := proto.Clone(good).(*machineidv1pb.SPIFFEFederation)
+ fed.Status = &machineidv1pb.SPIFFEFederationStatus{
+ CurrentBundleSyncedAt: timestamppb.Now(),
+ }
+ return fed
+ }(),
+ },
+ requireError: func(t require.TestingT, err error, i ...interface{}) {
+ require.Error(t, err)
+ require.True(t, trace.IsBadParameter(err))
+ require.ErrorContains(t, err, "status: cannot be set")
+ },
+ },
+ {
+ name: "validation is run",
+ user: authorizedUser.GetName(),
+ req: &machineidv1pb.CreateSPIFFEFederationRequest{
+ SpiffeFederation: func() *machineidv1pb.SPIFFEFederation {
+ fed := proto.Clone(good).(*machineidv1pb.SPIFFEFederation)
+ fed.Metadata.Name = "spiffe://im----invalid"
+ return fed
+ }(),
+ },
+ requireError: func(t require.TestingT, err error, i ...interface{}) {
+ require.Error(t, err)
+ require.True(t, trace.IsBadParameter(err))
+ require.ErrorContains(t, err, "metadata.name: must not include the spiffe:// prefix")
+ },
+ },
+ {
+ name: "unauthorized",
+ user: unauthorizedUser.GetName(),
+ req: &machineidv1pb.CreateSPIFFEFederationRequest{
+ SpiffeFederation: good,
+ },
+ requireError: func(t require.TestingT, err error, i ...interface{}) {
+ require.Error(t, err)
+ require.True(t, trace.IsAccessDenied(err))
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client, err := srv.NewClient(auth.TestUser(tt.user))
+ require.NoError(t, err)
+
+ mockEmitter.Reset()
+ got, err := client.SPIFFEFederationServiceClient().CreateSPIFFEFederation(ctx, tt.req)
+ tt.requireError(t, err)
+ if tt.requireSuccess {
+ // First check the response object matches our requested object.
+ require.Empty(
+ t,
+ cmp.Diff(
+ tt.req.SpiffeFederation,
+ got,
+ protocmp.Transform(),
+ protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"),
+ ),
+ )
+
+ // Then check the response is actually stored in the backend
+ got, err := srv.Auth().Services.SPIFFEFederations.GetSPIFFEFederation(
+ ctx, got.Metadata.GetName(),
+ )
+ require.NoError(t, err)
+ require.Empty(
+ t,
+ cmp.Diff(
+ tt.req.SpiffeFederation,
+ got,
+ protocmp.Transform(),
+ protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"),
+ ),
+ )
+ }
+ // Now we can ensure that the appropriate audit event was
+ // generated.
+ if tt.requireEvent != nil {
+ evt, ok := mockEmitter.LastEvent().(*events.SPIFFEFederationCreate)
+ require.True(t, ok)
+ require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr)
+ require.Empty(t, cmp.Diff(
+ evt,
+ tt.requireEvent,
+ cmpopts.IgnoreFields(events.SPIFFEFederationCreate{}, "ConnectionMetadata"),
+ ))
+ }
+ })
+ }
+}
+
+// TestSPIFFEFederationService_DeleteSPIFFEFederation is an integration test
+// that uses a real gRPC client/server.
+func TestSPIFFEFederationService_DeleteSPIFFEFederation(t *testing.T) {
+ t.Parallel()
+ srv, mockEmitter := newTestTLSServer(t)
+ ctx := context.Background()
+
+ nothingRole, err := types.NewRole("nothing", types.RoleSpecV6{})
+ require.NoError(t, err)
+ unauthorizedUser, err := auth.CreateUser(
+ ctx,
+ srv.Auth(),
+ "unauthorized",
+ // Nothing role necessary as otherwise authz engine gets confused.
+ nothingRole,
+ )
+ require.NoError(t, err)
+
+ role, err := types.NewRole("federation-deleter", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ {
+ Resources: []string{types.KindSPIFFEFederation},
+ Verbs: []string{types.VerbDelete},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ authorizedUser, err := auth.CreateUser(
+ ctx,
+ srv.Auth(),
+ "authorized",
+ // Nothing role necessary as otherwise authz engine gets confused.
+ role,
+ )
+ require.NoError(t, err)
+
+ name := "example.com"
+
+ tests := []struct {
+ name string
+ user string
+ create bool
+ requireError require.ErrorAssertionFunc
+ requireSuccess bool
+ requireEvent *events.SPIFFEFederationDelete
+ }{
+ {
+ name: "success",
+ user: authorizedUser.GetName(),
+ create: true,
+ requireError: require.NoError,
+ requireSuccess: true,
+ requireEvent: &events.SPIFFEFederationDelete{
+ Metadata: events.Metadata{
+ Type: libevents.SPIFFEFederationDeleteEvent,
+ Code: libevents.SPIFFEFederationDeleteCode,
+ },
+ ResourceMetadata: events.ResourceMetadata{
+ Name: name,
+ },
+ UserMetadata: events.UserMetadata{
+ User: authorizedUser.GetName(),
+ UserKind: events.UserKind_USER_KIND_HUMAN,
+ },
+ },
+ },
+ {
+ name: "not-exist",
+ user: authorizedUser.GetName(),
+ create: false,
+ requireError: func(t require.TestingT, err error, i ...interface{}) {
+ require.Error(t, err)
+ require.True(t, trace.IsNotFound(err))
+ },
+ },
+ {
+ name: "unauthorized",
+ user: unauthorizedUser.GetName(),
+ create: true,
+ requireError: func(t require.TestingT, err error, i ...interface{}) {
+ require.Error(t, err)
+ require.True(t, trace.IsAccessDenied(err))
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client, err := srv.NewClient(auth.TestUser(tt.user))
+ require.NoError(t, err)
+
+ resource := &machineidv1pb.SPIFFEFederation{
+ Kind: types.KindSPIFFEFederation,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: name,
+ },
+ Spec: &machineidv1pb.SPIFFEFederationSpec{
+ BundleSource: &machineidv1pb.SPIFFEFederationBundleSource{
+ HttpsWeb: &machineidv1pb.SPIFFEFederationBundleSourceHTTPSWeb{
+ BundleEndpointUrl: "https://example.com/bundle.json",
+ },
+ },
+ },
+ }
+
+ if tt.create {
+ _, err := srv.Auth().Services.SPIFFEFederations.CreateSPIFFEFederation(
+ ctx, resource,
+ )
+ require.NoError(t, err)
+ }
+
+ mockEmitter.Reset()
+ _, err = client.SPIFFEFederationServiceClient().DeleteSPIFFEFederation(ctx, &machineidv1pb.DeleteSPIFFEFederationRequest{
+ Name: resource.Metadata.GetName(),
+ })
+ tt.requireError(t, err)
+ if tt.requireSuccess {
+ // Check that it is no longer in the backend
+ _, err := srv.Auth().Services.SPIFFEFederations.GetSPIFFEFederation(
+ ctx, resource.Metadata.GetName(),
+ )
+ require.True(t, trace.IsNotFound(err))
+ }
+ // Now we can ensure that the appropriate audit event was
+ // generated.
+ if tt.requireEvent != nil {
+ evt, ok := mockEmitter.LastEvent().(*events.SPIFFEFederationDelete)
+ require.True(t, ok)
+ require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr)
+ require.Empty(t, cmp.Diff(
+ evt,
+ tt.requireEvent,
+ cmpopts.IgnoreFields(events.SPIFFEFederationDelete{}, "ConnectionMetadata"),
+ ))
+ }
+ })
+ }
+}
+
+// TestSPIFFEFederationService_GetSPIFFEFederation is an integration test
+// that uses a real gRPC client/server.
+func TestSPIFFEFederationService_GetSPIFFEFederation(t *testing.T) {
+ t.Parallel()
+ srv, _ := newTestTLSServer(t)
+ ctx := context.Background()
+
+ role, err := types.NewRole("federation-reader", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ {
+ Resources: []string{types.KindSPIFFEFederation},
+ Verbs: []string{types.VerbRead},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ authorizedUser, err := auth.CreateUser(
+ ctx,
+ srv.Auth(),
+ "authorized",
+ // Nothing role necessary as otherwise authz engine gets confused.
+ role,
+ )
+ require.NoError(t, err)
+
+ name := "example.com"
+ resource, err := srv.Auth().Services.SPIFFEFederations.CreateSPIFFEFederation(
+ ctx, &machineidv1pb.SPIFFEFederation{
+ Kind: types.KindSPIFFEFederation,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: name,
+ },
+ Spec: &machineidv1pb.SPIFFEFederationSpec{
+ BundleSource: &machineidv1pb.SPIFFEFederationBundleSource{
+ HttpsWeb: &machineidv1pb.SPIFFEFederationBundleSourceHTTPSWeb{
+ BundleEndpointUrl: "https://example.com/bundle.json",
+ },
+ },
+ },
+ },
+ )
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ user string
+ getName string
+ requireError require.ErrorAssertionFunc
+ requireSuccess bool
+ }{
+ {
+ name: "success",
+ user: authorizedUser.GetName(),
+ getName: name,
+ requireError: require.NoError,
+ requireSuccess: true,
+ },
+ {
+ name: "not-exist",
+ user: authorizedUser.GetName(),
+ getName: "do-not-exist",
+ requireError: func(t require.TestingT, err error, i ...interface{}) {
+ require.Error(t, err)
+ require.True(t, trace.IsNotFound(err))
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client, err := srv.NewClient(auth.TestUser(tt.user))
+ require.NoError(t, err)
+
+ got, err := client.SPIFFEFederationServiceClient().GetSPIFFEFederation(ctx, &machineidv1pb.GetSPIFFEFederationRequest{
+ Name: tt.getName,
+ })
+ tt.requireError(t, err)
+ if tt.requireSuccess {
+ require.Empty(
+ t,
+ cmp.Diff(
+ resource,
+ got,
+ protocmp.Transform(),
+ ),
+ )
+ }
+ })
+ }
+}
+
+// TestSPIFFEFederationService_ListSPIFFEFederations is an integration test
+// that uses a real gRPC client/server.
+func TestSPIFFEFederationService_ListSPIFFEFederations(t *testing.T) {
+ t.Parallel()
+ srv, _ := newTestTLSServer(t)
+ ctx := context.Background()
+
+ role, err := types.NewRole("federation-reader", types.RoleSpecV6{
+ Allow: types.RoleConditions{
+ Rules: []types.Rule{
+ {
+ Resources: []string{types.KindSPIFFEFederation},
+ Verbs: []string{types.VerbRead, types.VerbList},
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+ authorizedUser, err := auth.CreateUser(
+ ctx,
+ srv.Auth(),
+ "authorized",
+ // Nothing role necessary as otherwise authz engine gets confused.
+ role,
+ )
+ require.NoError(t, err)
+
+ // Create entities to list
+ createdObjects := []*machineidv1pb.SPIFFEFederation{}
+ // Create 49 entities to test an incomplete page at the end.
+ for i := 0; i < 49; i++ {
+ created, err := srv.AuthServer.AuthServer.Services.SPIFFEFederations.CreateSPIFFEFederation(
+ ctx,
+ &machineidv1pb.SPIFFEFederation{
+ Kind: types.KindSPIFFEFederation,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: fmt.Sprintf("%d.example.com", i),
+ },
+ Spec: &machineidv1pb.SPIFFEFederationSpec{
+ BundleSource: &machineidv1pb.SPIFFEFederationBundleSource{
+ HttpsWeb: &machineidv1pb.SPIFFEFederationBundleSourceHTTPSWeb{
+ BundleEndpointUrl: "https://example.com/bundle.json",
+ },
+ },
+ },
+ },
+ )
+ require.NoError(t, err)
+ createdObjects = append(createdObjects, created)
+ }
+
+ tests := []struct {
+ name string
+ user string
+ pageSize int
+ wantIterations int
+ requireError require.ErrorAssertionFunc
+ assertResponse bool
+ }{
+ {
+ name: "success - one page",
+ user: authorizedUser.GetName(),
+ wantIterations: 1,
+ requireError: require.NoError,
+ assertResponse: true,
+ },
+ {
+ name: "success - small pages",
+ pageSize: 10,
+ wantIterations: 5,
+ user: authorizedUser.GetName(),
+ requireError: require.NoError,
+ assertResponse: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client, err := srv.NewClient(auth.TestUser(tt.user))
+ require.NoError(t, err)
+
+ fetched := []*machineidv1pb.SPIFFEFederation{}
+ token := ""
+ iterations := 0
+ for {
+ iterations++
+ resp, err := client.SPIFFEFederationServiceClient().ListSPIFFEFederations(ctx, &machineidv1pb.ListSPIFFEFederationsRequest{
+ PageSize: int32(tt.pageSize),
+ PageToken: token,
+ })
+ tt.requireError(t, err)
+ if err != nil {
+ return
+ }
+ fetched = append(fetched, resp.SpiffeFederations...)
+ if resp.NextPageToken == "" {
+ break
+ }
+ token = resp.NextPageToken
+ }
+ if tt.assertResponse {
+ require.Equal(t, tt.wantIterations, iterations)
+ require.Len(t, fetched, 49)
+ for _, created := range createdObjects {
+ slices.ContainsFunc(fetched, func(federation *machineidv1pb.SPIFFEFederation) bool {
+ return proto.Equal(created, federation)
+ })
+ }
+ }
+ })
+ }
+}
diff --git a/lib/auth/machineid/machineidv1/workload_identity_service_test.go b/lib/auth/machineid/machineidv1/workload_identity_service_test.go
index b05d5d394bfa1..7216f99f635a9 100644
--- a/lib/auth/machineid/machineidv1/workload_identity_service_test.go
+++ b/lib/auth/machineid/machineidv1/workload_identity_service_test.go
@@ -38,7 +38,7 @@ import (
// real gRPC client/server.
func TestWorkloadIdentityService_SignX509SVIDs(t *testing.T) {
t.Parallel()
- srv := newTestTLSServer(t)
+ srv, _ := newTestTLSServer(t)
ctx := context.Background()
nothingRole, err := types.NewRole("nothing", types.RoleSpecV6{})
diff --git a/lib/services/presets.go b/lib/services/presets.go
index ce85e4d2d26b2..631bcaa2ca80c 100644
--- a/lib/services/presets.go
+++ b/lib/services/presets.go
@@ -178,6 +178,7 @@ func NewPresetEditorRole() types.Role {
types.NewRule(types.KindVnetConfig, RW()),
types.NewRule(types.KindBotInstance, RW()),
types.NewRule(types.KindAccessGraphSettings, RW()),
+ types.NewRule(types.KindSPIFFEFederation, RW()),
types.NewRule(types.KindNotification, RW()),
},
},
diff --git a/lib/services/role.go b/lib/services/role.go
index 09ebb46b1c782..5b56e937b7ec7 100644
--- a/lib/services/role.go
+++ b/lib/services/role.go
@@ -76,6 +76,7 @@ var DefaultImplicitRules = []types.Rule{
types.NewRule(types.KindKubernetesCluster, RO()),
types.NewRule(types.KindUsageEvent, []string{types.VerbCreate}),
types.NewRule(types.KindVnetConfig, RO()),
+ types.NewRule(types.KindSPIFFEFederation, RO()),
}
// DefaultCertAuthorityRules provides access the minimal set of resources
diff --git a/tool/tctl/common/collection.go b/tool/tctl/common/collection.go
index 3a1466b9fd4e8..d74fcf21cd418 100644
--- a/tool/tctl/common/collection.go
+++ b/tool/tctl/common/collection.go
@@ -1698,3 +1698,38 @@ func (c *botInstanceCollection) writeText(w io.Writer, verbose bool) error {
_, err := t.AsBuffer().WriteTo(w)
return trace.Wrap(err)
}
+
+type spiffeFederationCollection struct {
+ items []*machineidv1pb.SPIFFEFederation
+}
+
+func (c *spiffeFederationCollection) resources() []types.Resource {
+ r := make([]types.Resource, 0, len(c.items))
+ for _, resource := range c.items {
+ r = append(r, types.Resource153ToLegacy(resource))
+ }
+ return r
+}
+
+func (c *spiffeFederationCollection) writeText(w io.Writer, verbose bool) error {
+ headers := []string{"Name", "Last synced at"}
+
+ var rows [][]string
+ for _, item := range c.items {
+ lastSynced := "never"
+ if t := item.GetStatus().GetCurrentBundleSyncedAt().AsTime(); !t.IsZero() {
+ lastSynced = t.Format(time.RFC3339)
+ }
+ rows = append(rows, []string{
+ item.Metadata.Name,
+ lastSynced,
+ })
+ }
+
+ t := asciitable.MakeTable(headers, rows...)
+
+ // stable sort by name.
+ t.SortRowsBy([]int{0}, true)
+ _, err := t.AsBuffer().WriteTo(w)
+ return trace.Wrap(err)
+}
diff --git a/tool/tctl/common/resource_command.go b/tool/tctl/common/resource_command.go
index e1e5eae9f1ecf..3193cc28902aa 100644
--- a/tool/tctl/common/resource_command.go
+++ b/tool/tctl/common/resource_command.go
@@ -165,6 +165,7 @@ func (rc *ResourceCommand) Initialize(app *kingpin.Application, config *servicec
types.KindVnetConfig: rc.createVnetConfig,
types.KindAccessGraphSettings: rc.upsertAccessGraphSettings,
types.KindPlugin: rc.createPlugin,
+ types.KindSPIFFEFederation: rc.createSPIFFEFederation,
}
rc.UpdateHandlers = map[ResourceKind]ResourceCreateHandler{
types.KindUser: rc.updateUser,
@@ -959,6 +960,23 @@ func (rc *ResourceCommand) createCrownJewel(ctx context.Context, client *authcli
return nil
}
+func (rc *ResourceCommand) createSPIFFEFederation(ctx context.Context, client *authclient.Client, raw services.UnknownResource) error {
+ in, err := services.UnmarshalSPIFFEFederation(raw.Raw)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ c := client.SPIFFEFederationServiceClient()
+ if _, err := c.CreateSPIFFEFederation(ctx, &machineidv1pb.CreateSPIFFEFederationRequest{
+ SpiffeFederation: in,
+ }); err != nil {
+ return trace.Wrap(err)
+ }
+ fmt.Printf("SPIFFE Federation %q has been created\n", in.GetMetadata().GetName())
+
+ return nil
+}
+
func (rc *ResourceCommand) updateCrownJewel(ctx context.Context, client *authclient.Client, resource services.UnknownResource) error {
in, err := services.UnmarshalCrownJewel(resource.Raw)
if err != nil {
@@ -1785,6 +1803,15 @@ func (rc *ResourceCommand) Delete(ctx context.Context, client *authclient.Client
return trace.Wrap(err)
}
fmt.Printf("Access monitoring rule %q has been deleted\n", rc.ref.Name)
+ case types.KindSPIFFEFederation:
+ if _, err := client.SPIFFEFederationServiceClient().DeleteSPIFFEFederation(
+ ctx, &machineidv1pb.DeleteSPIFFEFederationRequest{
+ Name: rc.ref.Name,
+ },
+ ); err != nil {
+ return trace.Wrap(err)
+ }
+ fmt.Printf("SPIFFE federation %q has been deleted\n", rc.ref.Name)
default:
return trace.BadParameter("deleting resources of type %q is not supported", rc.ref.Kind)
}
@@ -2833,6 +2860,36 @@ func (rc *ResourceCommand) getCollection(ctx context.Context, client *authclient
return nil, trace.Wrap(err)
}
return &accessGraphSettings{accessGraphSettings: rec}, nil
+ case types.KindSPIFFEFederation:
+ if rc.ref.Name != "" {
+ resource, err := client.SPIFFEFederationServiceClient().GetSPIFFEFederation(ctx, &machineidv1pb.GetSPIFFEFederationRequest{
+ Name: rc.ref.Name,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return &spiffeFederationCollection{items: []*machineidv1pb.SPIFFEFederation{resource}}, nil
+ }
+
+ var resources []*machineidv1pb.SPIFFEFederation
+ pageToken := ""
+ for {
+ resp, err := client.SPIFFEFederationServiceClient().ListSPIFFEFederations(ctx, &machineidv1pb.ListSPIFFEFederationsRequest{
+ PageToken: pageToken,
+ })
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ resources = append(resources, resp.SpiffeFederations...)
+
+ if resp.NextPageToken == "" {
+ break
+ }
+ pageToken = resp.NextPageToken
+ }
+
+ return &spiffeFederationCollection{items: resources}, nil
case types.KindBotInstance:
if rc.ref.Name != "" && rc.ref.SubKind != "" {
// Gets a specific bot instance, e.g. bot_instance//