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//