diff --git a/api/types/events/oneof.go b/api/types/events/oneof.go index 18c15a094d94e..d1b3ce471d837 100644 --- a/api/types/events/oneof.go +++ b/api/types/events/oneof.go @@ -798,6 +798,7 @@ func ToOneOf(in AuditEvent) (*OneOf, error) { out.Event = &OneOf_ContactDelete{ ContactDelete: e, } + case *WorkloadIdentityCreate: out.Event = &OneOf_WorkloadIdentityCreate{ WorkloadIdentityCreate: e, diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go index 07ba460d29e91..e46e06a157133 100644 --- a/lib/auth/grpcserver.go +++ b/lib/auth/grpcserver.go @@ -74,6 +74,7 @@ import ( usersv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/users/v1" usertaskv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/usertasks/v1" vnetv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/vnet/v1" + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" userpreferencesv1pb "github.com/gravitational/teleport/api/gen/proto/go/userpreferences/v1" "github.com/gravitational/teleport/api/internalutils/stream" "github.com/gravitational/teleport/api/metadata" @@ -95,6 +96,7 @@ import ( "github.com/gravitational/teleport/lib/auth/kubewaitingcontainer/kubewaitingcontainerv1" "github.com/gravitational/teleport/lib/auth/loginrule/loginrulev1" "github.com/gravitational/teleport/lib/auth/machineid/machineidv1" + "github.com/gravitational/teleport/lib/auth/machineid/workloadidentityv1" "github.com/gravitational/teleport/lib/auth/notifications/notificationsv1" "github.com/gravitational/teleport/lib/auth/presence/presencev1" "github.com/gravitational/teleport/lib/auth/trust/trustv1" @@ -5095,6 +5097,18 @@ func NewGRPCServer(cfg GRPCServerConfig) (*GRPCServer, error) { } machineidv1pb.RegisterSPIFFEFederationServiceServer(server, spiffeFederationService) + workloadIdentityResourceService, err := workloadidentityv1.NewResourceService(&workloadidentityv1.ResourceServiceConfig{ + Authorizer: cfg.Authorizer, + Backend: cfg.AuthServer.Services.WorkloadIdentities, + Cache: cfg.AuthServer.Cache, + Emitter: cfg.Emitter, + Clock: cfg.AuthServer.GetClock(), + }) + if err != nil { + return nil, trace.Wrap(err, "creating workload identity resource service") + } + workloadidentityv1pb.RegisterWorkloadIdentityResourceServiceServer(server, workloadIdentityResourceService) + dbObjectImportRuleService, err := dbobjectimportrulev1.NewDatabaseObjectImportRuleService(dbobjectimportrulev1.DatabaseObjectImportRuleServiceConfig{ Authorizer: cfg.Authorizer, Backend: cfg.AuthServer.Services, diff --git a/lib/auth/machineid/workloadidentityv1/resource_service.go b/lib/auth/machineid/workloadidentityv1/resource_service.go new file mode 100644 index 0000000000000..6ca893e1b1fa9 --- /dev/null +++ b/lib/auth/machineid/workloadidentityv1/resource_service.go @@ -0,0 +1,362 @@ +// 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 workloadidentityv1 + +import ( + "context" + "log/slog" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/gravitational/teleport" + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/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 workloadIdentityReader interface { + GetWorkloadIdentity(ctx context.Context, name string) (*workloadidentityv1pb.WorkloadIdentity, error) + ListWorkloadIdentities(ctx context.Context, pageSize int, token string) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) +} + +type workloadIdentityReadWriter interface { + workloadIdentityReader + + CreateWorkloadIdentity(ctx context.Context, identity *workloadidentityv1pb.WorkloadIdentity) (*workloadidentityv1pb.WorkloadIdentity, error) + UpdateWorkloadIdentity(ctx context.Context, identity *workloadidentityv1pb.WorkloadIdentity) (*workloadidentityv1pb.WorkloadIdentity, error) + DeleteWorkloadIdentity(ctx context.Context, name string) error + UpsertWorkloadIdentity(ctx context.Context, identity *workloadidentityv1pb.WorkloadIdentity) (*workloadidentityv1pb.WorkloadIdentity, error) +} + +// ResourceServiceConfig holds configuration options for the ResourceService. +type ResourceServiceConfig struct { + Authorizer authz.Authorizer + Backend workloadIdentityReadWriter + Cache workloadIdentityReader + Clock clockwork.Clock + Emitter apievents.Emitter + Logger *slog.Logger +} + +// ResourceService is the gRPC service for managing workload identity resources. +// It implements the workloadidentityv1pb.WorkloadIdentityResourceServiceServer +type ResourceService struct { + workloadidentityv1pb.UnimplementedWorkloadIdentityResourceServiceServer + + authorizer authz.Authorizer + backend workloadIdentityReadWriter + cache workloadIdentityReader + clock clockwork.Clock + emitter apievents.Emitter + logger *slog.Logger +} + +// NewResourceService returns a new instance of the ResourceService. +func NewResourceService(cfg *ResourceServiceConfig) (*ResourceService, 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, "workload_identity_resource.service") + } + if cfg.Clock == nil { + cfg.Clock = clockwork.NewRealClock() + } + return &ResourceService{ + authorizer: cfg.Authorizer, + backend: cfg.Backend, + cache: cfg.Cache, + clock: cfg.Clock, + emitter: cfg.Emitter, + logger: cfg.Logger, + }, nil +} + +// GetWorkloadIdentity returns a WorkloadIdentity by name. +// Implements teleport.workloadidentity.v1.ResourceService/GetWorkloadIdentity +func (s *ResourceService) GetWorkloadIdentity( + ctx context.Context, req *workloadidentityv1pb.GetWorkloadIdentityRequest, +) (*workloadidentityv1pb.WorkloadIdentity, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentity, types.VerbRead); err != nil { + return nil, trace.Wrap(err) + } + + if req.Name == "" { + return nil, trace.BadParameter("name: must be non-empty") + } + + resource, err := s.cache.GetWorkloadIdentity(ctx, req.Name) + if err != nil { + return nil, trace.Wrap(err) + } + + return resource, nil +} + +// ListWorkloadIdentities returns a list of WorkloadIdentity resources. It +// follows the Google API design guidelines for list pagination. +// Implements teleport.workloadidentity.v1.ResourceService/ListWorkloadIdentities +func (s *ResourceService) ListWorkloadIdentities( + ctx context.Context, req *workloadidentityv1pb.ListWorkloadIdentitiesRequest, +) (*workloadidentityv1pb.ListWorkloadIdentitiesResponse, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentity, types.VerbRead, types.VerbList); err != nil { + return nil, trace.Wrap(err) + } + + resources, nextToken, err := s.cache.ListWorkloadIdentities( + ctx, + int(req.PageSize), + req.PageToken, + ) + if err != nil { + return nil, trace.Wrap(err) + } + + return &workloadidentityv1pb.ListWorkloadIdentitiesResponse{ + WorkloadIdentities: resources, + NextPageToken: nextToken, + }, nil +} + +// DeleteWorkloadIdentity deletes a WorkloadIdentity by name. +// Implements teleport.workloadidentity.v1.ResourceService/DeleteWorkloadIdentity +func (s *ResourceService) DeleteWorkloadIdentity( + ctx context.Context, req *workloadidentityv1pb.DeleteWorkloadIdentityRequest, +) (*emptypb.Empty, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentity, 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.DeleteWorkloadIdentity(ctx, req.Name); err != nil { + return nil, trace.Wrap(err) + } + + if err := s.emitter.EmitAuditEvent(ctx, &apievents.WorkloadIdentityDelete{ + Metadata: apievents.Metadata{ + Code: events.WorkloadIdentityDeleteCode, + Type: events.WorkloadIdentityDeleteEvent, + }, + 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 WorkloadIdentity", + "error", err, + ) + } + + return &emptypb.Empty{}, nil +} + +// CreateWorkloadIdentity creates a new WorkloadIdentity. +// Implements teleport.workloadidentity.v1.ResourceService/CreateWorkloadIdentity +func (s *ResourceService) CreateWorkloadIdentity( + ctx context.Context, req *workloadidentityv1pb.CreateWorkloadIdentityRequest, +) (*workloadidentityv1pb.WorkloadIdentity, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentity, types.VerbCreate); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + created, err := s.backend.CreateWorkloadIdentity(ctx, req.WorkloadIdentity) + if err != nil { + return nil, trace.Wrap(err) + } + + evt := &apievents.WorkloadIdentityCreate{ + Metadata: apievents.Metadata{ + Code: events.WorkloadIdentityCreateCode, + Type: events.WorkloadIdentityCreateEvent, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ConnectionMetadata: authz.ConnectionMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: req.WorkloadIdentity.Metadata.Name, + }, + } + evt.WorkloadIdentityData, err = resourceToStruct(created) + if err != nil { + s.logger.ErrorContext( + ctx, + "Failed to convert WorkloadIdentity to struct for audit log", + "error", err, + ) + } + if err := s.emitter.EmitAuditEvent(ctx, evt); err != nil { + s.logger.ErrorContext( + ctx, "Failed to emit audit event for creation of WorkloadIdentity", + "error", err, + ) + } + + return created, nil +} + +// UpdateWorkloadIdentity updates an existing WorkloadIdentity. +// Implements teleport.workloadidentity.v1.ResourceService/UpdateWorkloadIdentity +func (s *ResourceService) UpdateWorkloadIdentity( + ctx context.Context, req *workloadidentityv1pb.UpdateWorkloadIdentityRequest, +) (*workloadidentityv1pb.WorkloadIdentity, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind(types.KindWorkloadIdentity, types.VerbUpdate); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + created, err := s.backend.UpdateWorkloadIdentity(ctx, req.WorkloadIdentity) + if err != nil { + return nil, trace.Wrap(err) + } + + evt := &apievents.WorkloadIdentityUpdate{ + Metadata: apievents.Metadata{ + Code: events.WorkloadIdentityUpdateCode, + Type: events.WorkloadIdentityUpdateEvent, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ConnectionMetadata: authz.ConnectionMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: req.WorkloadIdentity.Metadata.Name, + }, + } + evt.WorkloadIdentityData, err = resourceToStruct(created) + if err != nil { + s.logger.ErrorContext( + ctx, + "Failed to convert WorkloadIdentity to struct for audit log", + "error", err, + ) + } + if err := s.emitter.EmitAuditEvent(ctx, evt); err != nil { + s.logger.ErrorContext( + ctx, "Failed to emit audit event for updating of WorkloadIdentity", + "error", err, + ) + } + + return created, nil +} + +// UpsertWorkloadIdentity updates or creates an existing WorkloadIdentity. +// Implements teleport.workloadidentity.v1.ResourceService/UpsertWorkloadIdentity +func (s *ResourceService) UpsertWorkloadIdentity( + ctx context.Context, req *workloadidentityv1pb.UpsertWorkloadIdentityRequest, +) (*workloadidentityv1pb.WorkloadIdentity, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.CheckAccessToKind( + types.KindWorkloadIdentity, types.VerbCreate, types.VerbUpdate, + ); err != nil { + return nil, trace.Wrap(err) + } + if err := authCtx.AuthorizeAdminAction(); err != nil { + return nil, trace.Wrap(err) + } + + created, err := s.backend.UpsertWorkloadIdentity(ctx, req.WorkloadIdentity) + if err != nil { + return nil, trace.Wrap(err) + } + + evt := &apievents.WorkloadIdentityCreate{ + Metadata: apievents.Metadata{ + Code: events.WorkloadIdentityCreateCode, + Type: events.WorkloadIdentityCreateEvent, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ConnectionMetadata: authz.ConnectionMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: req.WorkloadIdentity.Metadata.Name, + }, + } + evt.WorkloadIdentityData, err = resourceToStruct(created) + if err != nil { + s.logger.ErrorContext( + ctx, + "Failed to convert WorkloadIdentity to struct for audit log", + "error", err, + ) + } + if err := s.emitter.EmitAuditEvent(ctx, evt); err != nil { + s.logger.ErrorContext( + ctx, "Failed to emit audit event for upsertion of WorkloadIdentity", + "error", err, + ) + } + + return created, nil +} + +func resourceToStruct(in *workloadidentityv1pb.WorkloadIdentity) (*apievents.Struct, error) { + data, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(in) + if err != nil { + return nil, trace.Wrap(err, "marshaling resource for audit log") + } + out := &apievents.Struct{} + if err := out.UnmarshalJSON(data); err != nil { + return nil, trace.Wrap(err, "unmarshaling resource for audit log") + } + return out, nil +} diff --git a/lib/auth/machineid/workloadidentityv1/workloadidentityv1_test.go b/lib/auth/machineid/workloadidentityv1/workloadidentityv1_test.go new file mode 100644 index 0000000000000..1c0601a34dd54 --- /dev/null +++ b/lib/auth/machineid/workloadidentityv1/workloadidentityv1_test.go @@ -0,0 +1,984 @@ +// 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 workloadidentityv1_test + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "slices" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/testing/protocmp" + + 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" + "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/auth/authclient" + libevents "github.com/gravitational/teleport/lib/events" + "github.com/gravitational/teleport/lib/events/eventstest" + "github.com/gravitational/teleport/lib/modules" +) + +func TestMain(m *testing.M) { + modules.SetInsecureTestMode(true) + os.Exit(m.Run()) +} + +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) + + emitter := &eventstest.MockRecorderEmitter{} + srv, err := as.NewTestTLSServer(func(config *auth.TestTLSServerConfig) { + config.APIConfig.Emitter = emitter + }) + require.NoError(t, err) + + t.Cleanup(func() { + err := srv.Close() + if errors.Is(err, net.ErrClosed) { + return + } + require.NoError(t, err) + }) + + return srv, emitter +} + +func TestResourceService_CreateWorkloadIdentity(t *testing.T) { + t.Parallel() + srv, eventRecorder := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentity}, + Verbs: []string{types.VerbCreate}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identity + preExisting, err := srv.Auth().CreateWorkloadIdentity( + ctx, + &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "preexisting", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.CreateWorkloadIdentityRequest + requireError require.ErrorAssertionFunc + checkResultReturned bool + requireEvent *events.WorkloadIdentityCreate + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.CreateWorkloadIdentityRequest{ + WorkloadIdentity: &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "new", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }, + }, + requireError: require.NoError, + checkResultReturned: true, + requireEvent: &events.WorkloadIdentityCreate{ + Metadata: events.Metadata{ + Code: libevents.WorkloadIdentityCreateCode, + Type: libevents.WorkloadIdentityCreateEvent, + }, + ResourceMetadata: events.ResourceMetadata{ + Name: "new", + }, + UserMetadata: events.UserMetadata{ + User: authorizedUser.GetName(), + UserKind: events.UserKind_USER_KIND_HUMAN, + }, + }, + }, + { + name: "pre-existing", + client: authorizedClient, + req: &workloadidentityv1pb.CreateWorkloadIdentityRequest{ + WorkloadIdentity: &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: preExisting.GetMetadata().GetName(), + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAlreadyExists(err)) + }, + }, + { + name: "validation fail", + client: authorizedClient, + req: &workloadidentityv1pb.CreateWorkloadIdentityRequest{ + WorkloadIdentity: &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "new", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "", + }, + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err)) + require.ErrorContains(t, err, "spec.spiffe.id: is required") + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.CreateWorkloadIdentityRequest{ + WorkloadIdentity: &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "unauthorized", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eventRecorder.Reset() + client := workloadidentityv1pb.NewWorkloadIdentityResourceServiceClient( + tt.client.GetConnection(), + ) + res, err := client.CreateWorkloadIdentity(ctx, tt.req) + tt.requireError(t, err) + + if tt.checkResultReturned { + require.NotEmpty(t, res.Metadata.Revision) + // Expect returned result to match request, but also have a + // revision + require.Empty( + t, + cmp.Diff( + res, + tt.req.WorkloadIdentity, + protocmp.Transform(), + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + ), + ) + // Expect the value fetched from the store to match returned + // item. + fetched, err := srv.Auth().GetWorkloadIdentity(ctx, res.Metadata.Name) + require.NoError(t, err) + require.Empty( + t, + cmp.Diff( + res, + fetched, + protocmp.Transform(), + ), + ) + } + if tt.requireEvent != nil { + evt, ok := eventRecorder.LastEvent().(*events.WorkloadIdentityCreate) + require.True(t, ok) + require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr) + require.Empty(t, cmp.Diff( + evt, + tt.requireEvent, + cmpopts.IgnoreFields(events.WorkloadIdentityCreate{}, "ConnectionMetadata", "WorkloadIdentityData"), + )) + } + }) + } +} + +func TestResourceService_DeleteWorkloadIdentity(t *testing.T) { + t.Parallel() + srv, eventRecorder := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentity}, + Verbs: []string{types.VerbDelete}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identity + preExisting, err := srv.Auth().CreateWorkloadIdentity( + ctx, + &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "preexisting", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.DeleteWorkloadIdentityRequest + requireError require.ErrorAssertionFunc + checkNonExisting bool + requireEvent *events.WorkloadIdentityDelete + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.DeleteWorkloadIdentityRequest{ + Name: preExisting.GetMetadata().GetName(), + }, + requireError: require.NoError, + checkNonExisting: true, + requireEvent: &events.WorkloadIdentityDelete{ + Metadata: events.Metadata{ + Code: libevents.WorkloadIdentityDeleteCode, + Type: libevents.WorkloadIdentityDeleteEvent, + }, + ResourceMetadata: events.ResourceMetadata{ + Name: preExisting.GetMetadata().GetName(), + }, + UserMetadata: events.UserMetadata{ + User: authorizedUser.GetName(), + UserKind: events.UserKind_USER_KIND_HUMAN, + }, + }, + }, + { + name: "non-existing", + client: authorizedClient, + req: &workloadidentityv1pb.DeleteWorkloadIdentityRequest{ + Name: "i-do-not-exist", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsNotFound(err)) + }, + }, + { + name: "validation fail", + client: authorizedClient, + req: &workloadidentityv1pb.DeleteWorkloadIdentityRequest{ + Name: "", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err)) + require.ErrorContains(t, err, "name: must be non-empty") + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.DeleteWorkloadIdentityRequest{ + Name: "unauthorized", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eventRecorder.Reset() + client := workloadidentityv1pb.NewWorkloadIdentityResourceServiceClient( + tt.client.GetConnection(), + ) + _, err := client.DeleteWorkloadIdentity(ctx, tt.req) + tt.requireError(t, err) + + if tt.checkNonExisting { + _, err := srv.Auth().GetWorkloadIdentity(ctx, tt.req.Name) + require.True(t, trace.IsNotFound(err)) + } + if tt.requireEvent != nil { + evt, ok := eventRecorder.LastEvent().(*events.WorkloadIdentityDelete) + require.True(t, ok) + require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr) + require.Empty(t, cmp.Diff( + tt.requireEvent, + evt, + cmpopts.IgnoreFields(events.WorkloadIdentityDelete{}, "ConnectionMetadata"), + )) + } + }) + } +} + +func TestResourceService_GetWorkloadIdentity(t *testing.T) { + t.Parallel() + srv, _ := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentity}, + Verbs: []string{types.VerbRead}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identity + preExisting, err := srv.Auth().CreateWorkloadIdentity( + ctx, + &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "preexisting", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.GetWorkloadIdentityRequest + wantRes *workloadidentityv1pb.WorkloadIdentity + requireError require.ErrorAssertionFunc + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.GetWorkloadIdentityRequest{ + Name: preExisting.GetMetadata().GetName(), + }, + wantRes: preExisting, + requireError: require.NoError, + }, + { + name: "non-existing", + client: authorizedClient, + req: &workloadidentityv1pb.GetWorkloadIdentityRequest{ + Name: "i-do-not-exist", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsNotFound(err)) + }, + }, + { + name: "validation fail", + client: authorizedClient, + req: &workloadidentityv1pb.GetWorkloadIdentityRequest{ + Name: "", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err)) + require.ErrorContains(t, err, "name: must be non-empty") + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.GetWorkloadIdentityRequest{ + Name: "unauthorized", + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := workloadidentityv1pb.NewWorkloadIdentityResourceServiceClient( + tt.client.GetConnection(), + ) + got, err := client.GetWorkloadIdentity(ctx, tt.req) + tt.requireError(t, err) + + if tt.wantRes != nil { + require.Empty( + t, + cmp.Diff( + tt.wantRes, + got, + protocmp.Transform(), + ), + ) + } + }) + } +} + +func TestResourceService_ListWorkloadIdentities(t *testing.T) { + t.Parallel() + srv, _ := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentity}, + Verbs: []string{types.VerbRead, types.VerbList}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identities + // Two complete pages of ten, plus one incomplete page of nine + created := []*workloadidentityv1pb.WorkloadIdentity{} + for i := 0; i < 29; i++ { + r, err := srv.Auth().CreateWorkloadIdentity( + ctx, + &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: fmt.Sprintf("preexisting-%d", i), + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }) + require.NoError(t, err) + created = append(created, r) + } + + t.Run("unauthorized", func(t *testing.T) { + client := workloadidentityv1pb.NewWorkloadIdentityResourceServiceClient( + unauthorizedClient.GetConnection(), + ) + + _, err := client.ListWorkloadIdentities(ctx, &workloadidentityv1pb.ListWorkloadIdentitiesRequest{}) + require.True(t, trace.IsAccessDenied(err)) + }) + + t.Run("success - default page", func(t *testing.T) { + client := workloadidentityv1pb.NewWorkloadIdentityResourceServiceClient( + authorizedClient.GetConnection(), + ) + + // For the default page size, we expect to get all results in one page + res, err := client.ListWorkloadIdentities(ctx, &workloadidentityv1pb.ListWorkloadIdentitiesRequest{}) + require.NoError(t, err) + require.Len(t, res.WorkloadIdentities, 29) + require.Empty(t, res.NextPageToken) + for _, created := range created { + slices.ContainsFunc(res.WorkloadIdentities, func(resource *workloadidentityv1pb.WorkloadIdentity) bool { + return proto.Equal(created, resource) + }) + } + }) + + t.Run("success - page size 10", func(t *testing.T) { + client := workloadidentityv1pb.NewWorkloadIdentityResourceServiceClient( + authorizedClient.GetConnection(), + ) + + fetched := []*workloadidentityv1pb.WorkloadIdentity{} + token := "" + iterations := 0 + for { + iterations++ + res, err := client.ListWorkloadIdentities(ctx, &workloadidentityv1pb.ListWorkloadIdentitiesRequest{ + PageSize: 10, + PageToken: token, + }) + require.NoError(t, err) + fetched = append(fetched, res.WorkloadIdentities...) + if res.NextPageToken == "" { + break + } + token = res.NextPageToken + } + + require.Len(t, fetched, 29) + require.Equal(t, 3, iterations) + for _, created := range created { + slices.ContainsFunc(fetched, func(resource *workloadidentityv1pb.WorkloadIdentity) bool { + return proto.Equal(created, resource) + }) + } + }) +} + +func TestResourceService_UpdateWorkloadIdentity(t *testing.T) { + t.Parallel() + srv, eventRecorder := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentity}, + Verbs: []string{types.VerbUpdate}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + // Create a pre-existing workload identity + preExisting, err := srv.Auth().CreateWorkloadIdentity( + ctx, + &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "preexisting", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }) + require.NoError(t, err) + preExisting2, err := srv.Auth().CreateWorkloadIdentity( + ctx, + &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "preexisting-2", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.UpdateWorkloadIdentityRequest + requireError require.ErrorAssertionFunc + checkResultReturned bool + requireEvent *events.WorkloadIdentityUpdate + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.UpdateWorkloadIdentityRequest{ + WorkloadIdentity: preExisting, + }, + requireError: require.NoError, + checkResultReturned: true, + requireEvent: &events.WorkloadIdentityUpdate{ + Metadata: events.Metadata{ + Code: libevents.WorkloadIdentityUpdateCode, + Type: libevents.WorkloadIdentityUpdateEvent, + }, + ResourceMetadata: events.ResourceMetadata{ + Name: preExisting.GetMetadata().GetName(), + }, + UserMetadata: events.UserMetadata{ + User: authorizedUser.GetName(), + UserKind: events.UserKind_USER_KIND_HUMAN, + }, + }, + }, + { + name: "incorrect revision", + client: authorizedClient, + req: (func() *workloadidentityv1pb.UpdateWorkloadIdentityRequest { + preExisting2.Metadata.Revision = "incorrect" + return &workloadidentityv1pb.UpdateWorkloadIdentityRequest{ + WorkloadIdentity: preExisting2, + } + })(), + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsCompareFailed(err)) + }, + }, + { + name: "not existing", + client: authorizedClient, + req: &workloadidentityv1pb.UpdateWorkloadIdentityRequest{ + WorkloadIdentity: &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "new", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/test", + }, + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.Error(t, err) + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.UpdateWorkloadIdentityRequest{ + WorkloadIdentity: preExisting, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eventRecorder.Reset() + client := workloadidentityv1pb.NewWorkloadIdentityResourceServiceClient( + tt.client.GetConnection(), + ) + res, err := client.UpdateWorkloadIdentity(ctx, tt.req) + tt.requireError(t, err) + + if tt.checkResultReturned { + require.NotEmpty(t, res.Metadata.Revision) + require.NotEqual(t, tt.req.WorkloadIdentity.GetMetadata().GetRevision(), res.Metadata.Revision) + // Expect returned result to match request, but also have a + // revision + require.Empty( + t, + cmp.Diff( + res, + tt.req.WorkloadIdentity, + protocmp.Transform(), + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + ), + ) + // Expect the value fetched from the store to match returned + // item. + fetched, err := srv.Auth().GetWorkloadIdentity(ctx, res.Metadata.Name) + require.NoError(t, err) + require.Empty( + t, + cmp.Diff( + res, + fetched, + protocmp.Transform(), + ), + ) + } + if tt.requireEvent != nil { + evt, ok := eventRecorder.LastEvent().(*events.WorkloadIdentityUpdate) + require.True(t, ok) + require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr) + require.Empty(t, cmp.Diff( + evt, + tt.requireEvent, + cmpopts.IgnoreFields(events.WorkloadIdentityUpdate{}, "ConnectionMetadata", "WorkloadIdentityData"), + )) + } + }) + } +} + +func TestResourceService_UpsertWorkloadIdentity(t *testing.T) { + t.Parallel() + srv, eventRecorder := newTestTLSServer(t) + ctx := context.Background() + + authorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "authorized", + []string{}, + []types.Rule{ + { + Resources: []string{types.KindWorkloadIdentity}, + Verbs: []string{types.VerbCreate, types.VerbUpdate}, + }, + }) + require.NoError(t, err) + authorizedClient, err := srv.NewClient(auth.TestUser(authorizedUser.GetName())) + require.NoError(t, err) + unauthorizedUser, _, err := auth.CreateUserAndRole( + srv.Auth(), + "unauthorized", + []string{}, + []types.Rule{}, + ) + require.NoError(t, err) + unauthorizedClient, err := srv.NewClient(auth.TestUser(unauthorizedUser.GetName())) + require.NoError(t, err) + + tests := []struct { + name string + client *authclient.Client + req *workloadidentityv1pb.UpsertWorkloadIdentityRequest + requireError require.ErrorAssertionFunc + checkResultReturned bool + requireEvent *events.WorkloadIdentityCreate + }{ + { + name: "success", + client: authorizedClient, + req: &workloadidentityv1pb.UpsertWorkloadIdentityRequest{ + WorkloadIdentity: &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "new", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }, + }, + requireError: require.NoError, + checkResultReturned: true, + requireEvent: &events.WorkloadIdentityCreate{ + Metadata: events.Metadata{ + Code: libevents.WorkloadIdentityCreateCode, + Type: libevents.WorkloadIdentityCreateEvent, + }, + ResourceMetadata: events.ResourceMetadata{ + Name: "new", + }, + UserMetadata: events.UserMetadata{ + User: authorizedUser.GetName(), + UserKind: events.UserKind_USER_KIND_HUMAN, + }, + }, + }, + { + name: "validation fail", + client: authorizedClient, + req: &workloadidentityv1pb.UpsertWorkloadIdentityRequest{ + WorkloadIdentity: &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "new", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "", + }, + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsBadParameter(err)) + require.ErrorContains(t, err, "spec.spiffe.id: is required") + }, + }, + { + name: "unauthorized", + client: unauthorizedClient, + req: &workloadidentityv1pb.UpsertWorkloadIdentityRequest{ + WorkloadIdentity: &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "unauthorized", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/example", + }, + }, + }, + }, + requireError: func(t require.TestingT, err error, i ...interface{}) { + require.True(t, trace.IsAccessDenied(err)) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + eventRecorder.Reset() + client := workloadidentityv1pb.NewWorkloadIdentityResourceServiceClient( + tt.client.GetConnection(), + ) + res, err := client.UpsertWorkloadIdentity(ctx, tt.req) + tt.requireError(t, err) + + if tt.checkResultReturned { + require.NotEmpty(t, res.Metadata.Revision) + // Expect returned result to match request, but also have a + // revision + require.Empty( + t, + cmp.Diff( + res, + tt.req.WorkloadIdentity, + protocmp.Transform(), + protocmp.IgnoreFields(&headerv1.Metadata{}, "revision"), + ), + ) + // Expect the value fetched from the store to match returned + // item. + fetched, err := srv.Auth().GetWorkloadIdentity(ctx, res.Metadata.Name) + require.NoError(t, err) + require.Empty( + t, + cmp.Diff( + res, + fetched, + protocmp.Transform(), + ), + ) + } + if tt.requireEvent != nil { + evt, ok := eventRecorder.LastEvent().(*events.WorkloadIdentityCreate) + require.True(t, ok) + require.NotEmpty(t, evt.ConnectionMetadata.RemoteAddr) + require.Empty(t, cmp.Diff( + evt, + tt.requireEvent, + cmpopts.IgnoreFields(events.WorkloadIdentityCreate{}, "ConnectionMetadata", "WorkloadIdentityData"), + )) + } + }) + } +} diff --git a/lib/services/local/spiffe_federations_test.go b/lib/services/local/spiffe_federations_test.go index edc9a1ac3c1f9..7ce3ffaa25529 100644 --- a/lib/services/local/spiffe_federations_test.go +++ b/lib/services/local/spiffe_federations_test.go @@ -246,7 +246,7 @@ func TestSPIFFEFederationService_DeleteSPIFFEFederation(t *testing.T) { require.True(t, trace.IsNotFound(err)) }) t.Run("not found", func(t *testing.T) { - _, err := service.GetSPIFFEFederation(ctx, "foo.example.com") + err := service.DeleteSPIFFEFederation(ctx, "foo.example.com") require.Error(t, err) require.True(t, trace.IsNotFound(err)) }) diff --git a/lib/services/presets.go b/lib/services/presets.go index 6af90e02110c4..d82ba05a4f4b2 100644 --- a/lib/services/presets.go +++ b/lib/services/presets.go @@ -184,6 +184,7 @@ func NewPresetEditorRole() types.Role { types.NewRule(types.KindUserTask, RW()), types.NewRule(types.KindIdentityCenter, RW()), types.NewRule(types.KindContact, RW()), + types.NewRule(types.KindWorkloadIdentity, RW()), }, }, }, @@ -614,6 +615,7 @@ func NewPresetTerraformProviderRole() types.Role { types.KindInstaller, types.KindAccessMonitoringRule, types.KindStaticHostUser, + types.KindWorkloadIdentity, }, Verbs: RW(), },