diff --git a/api/types/constants.go b/api/types/constants.go index 9f11b337a33a6..67582ecbabe30 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -71,6 +71,8 @@ const ( KindBotInstance = "bot_instance" // KindSPIFFEFederation is a SPIFFE federation resource KindSPIFFEFederation = "spiffe_federation" + // KindWorkloadIdentity is a workload identity resource + KindWorkloadIdentity = "workload_identity" // KindHostCert is a host certificate KindHostCert = "host_cert" diff --git a/api/types/events/oneof.go b/api/types/events/oneof.go index 8f3c73e7ceb24..d71721fba910f 100644 --- a/api/types/events/oneof.go +++ b/api/types/events/oneof.go @@ -790,6 +790,7 @@ func ToOneOf(in AuditEvent) (*OneOf, error) { out.Event = &OneOf_AutoUpdateVersionDelete{ AutoUpdateVersionDelete: e, } + case *WorkloadIdentityCreate: out.Event = &OneOf_WorkloadIdentityCreate{ WorkloadIdentityCreate: e, diff --git a/lib/auth/machineid/workloadidentityv1/resource_service.go b/lib/auth/machineid/workloadidentityv1/resource_service.go new file mode 100644 index 0000000000000..46ade70df0645 --- /dev/null +++ b/lib/auth/machineid/workloadidentityv1/resource_service.go @@ -0,0 +1,278 @@ +// 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/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 +} + +// 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.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 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) + } + + if err := s.emitter.EmitAuditEvent(ctx, &apievents.WorkloadIdentityCreate{ + Metadata: apievents.Metadata{ + Code: events.SPIFFEFederationCreateCode, + Type: events.SPIFFEFederationCreateEvent, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ConnectionMetadata: authz.ConnectionMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: req.WorkloadIdentity.Metadata.Name, + }, + }); 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.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.VerbUpdate); 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) + } + + if err := s.emitter.EmitAuditEvent(ctx, &apievents.WorkloadIdentityUpdate{ + Metadata: apievents.Metadata{ + Code: events.SPIFFEFederationCreateCode, + Type: events.SPIFFEFederationCreateEvent, + }, + UserMetadata: authz.ClientUserMetadata(ctx), + ConnectionMetadata: authz.ConnectionMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: req.WorkloadIdentity.Metadata.Name, + }, + }); err != nil { + s.logger.ErrorContext( + ctx, "Failed to emit audit event for creation of WorkloadIdentity", + "error", err, + ) + } + + return created, nil +} diff --git a/lib/events/api.go b/lib/events/api.go index 92eafcff2b8d9..25175d5472f56 100644 --- a/lib/events/api.go +++ b/lib/events/api.go @@ -766,6 +766,13 @@ const ( // SPIFFEFederationDeleteEvent is emitted when a SPIFFE federation is deleted. SPIFFEFederationDeleteEvent = "spiffe.federation.delete" + // WorkloadIdentityCreateEvent is emitted when a WorkloadIdentity is created. + WorkloadIdentityCreateEvent = "workload_identity.create" + // WorkloadIdentityUpdateEvent is emitted when a WorkloadIdentity is updated. + WorkloadIdentityUpdateEvent = "workload_identity.update" + // WorkloadIdentityDeleteEvent is emitted when a WorkloadIdentity is deleted. + WorkloadIdentityDeleteEvent = "workload_identity.delete" + // AuthPreferenceUpdateEvent is emitted when a user updates the cluster authentication preferences. AuthPreferenceUpdateEvent = "auth_preference.update" // ClusterNetworkingConfigUpdateEvent is emitted when a user updates the cluster networking configuration. diff --git a/lib/events/codes.go b/lib/events/codes.go index bb916ea2df543..d1c9837b485ab 100644 --- a/lib/events/codes.go +++ b/lib/events/codes.go @@ -427,6 +427,13 @@ const ( // BotDeleteCode is the `bot.delete` event code. BotDeleteCode = "TB003I" + // WorkloadIdentityCreateCode is the `workload_identity.create` event code. + WorkloadIdentityCreateCode = "TWID001I" + // WorkloadIdentityDeleteCode is the `workload_identity.delete` event code. + WorkloadIdentityDeleteCode = "TWID002I" + // WorkloadIdentityUpdateCode is the `workload_identity.update` event code. + WorkloadIdentityUpdateCode = "TWID003I" + // LockCreatedCode is the lock created event code. LockCreatedCode = "TLK00I" // LockDeletedCode is the lock deleted event code. diff --git a/lib/services/local/workload_identity.go b/lib/services/local/workload_identity.go new file mode 100644 index 0000000000000..49b6da073a928 --- /dev/null +++ b/lib/services/local/workload_identity.go @@ -0,0 +1,115 @@ +// 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 local + +import ( + "context" + + "github.com/gravitational/trace" + + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/services/local/generic" +) + +const ( + workloadIdentityPrefix = "workload_identity" +) + +// WorkloadIdentityService exposes backend functionality for storing +// WorkloadIdentity resxources +type WorkloadIdentityService struct { + service *generic.ServiceWrapper[*workloadidentityv1pb.WorkloadIdentity] +} + +// NewWorkloadIdentityService creates a new WorkloadIdentityService +func NewWorkloadIdentityService(b backend.Backend) (*WorkloadIdentityService, error) { + service, err := generic.NewServiceWrapper( + generic.ServiceWrapperConfig[*workloadidentityv1pb.WorkloadIdentity]{ + Backend: b, + ResourceKind: types.KindWorkloadIdentity, + BackendPrefix: backend.NewKey(workloadIdentityPrefix), + MarshalFunc: services.MarshalWorkloadIdentity, + UnmarshalFunc: services.UnmarshalWorkloadIdentity, + ValidateFunc: services.ValidateWorkloadIdentity, + }) + if err != nil { + return nil, trace.Wrap(err) + } + return &WorkloadIdentityService{ + service: service, + }, nil +} + +// CreateWorkloadIdentity inserts a new WorkloadIdentity into the backend. +func (b *WorkloadIdentityService) CreateWorkloadIdentity( + ctx context.Context, resource *workloadidentityv1pb.WorkloadIdentity, +) (*workloadidentityv1pb.WorkloadIdentity, error) { + created, err := b.service.CreateResource(ctx, resource) + return created, trace.Wrap(err) +} + +// GetWorkloadIdentity retrieves a specific WorkloadIdentity given a name +func (b *WorkloadIdentityService) GetWorkloadIdentity( + ctx context.Context, name string, +) (*workloadidentityv1pb.WorkloadIdentity, error) { + resource, err := b.service.GetResource(ctx, name) + return resource, trace.Wrap(err) +} + +// ListWorkloadIdentities lists all WorkloadIdentitys using a given page size +// and last key. +func (b *WorkloadIdentityService) ListWorkloadIdentities( + ctx context.Context, pageSize int, currentToken string, +) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { + r, nextToken, err := b.service.ListResources(ctx, pageSize, currentToken) + return r, nextToken, trace.Wrap(err) +} + +// DeleteWorkloadIdentity deletes a specific WorkloadIdentitys. +func (b *WorkloadIdentityService) DeleteWorkloadIdentity( + ctx context.Context, name string, +) error { + return trace.Wrap(b.service.DeleteResource(ctx, name)) +} + +// DeleteAllWorkloadIdentitys deletes all SPIFFE resources, this is typically +// only meant to be used by the cache. +func (b *WorkloadIdentityService) DeleteAllWorkloadIdentitys( + ctx context.Context, +) error { + return trace.Wrap(b.service.DeleteAllResources(ctx)) +} + +// UpsertWorkloadIdentity upserts a WorkloadIdentitys. Prefer using +// CreateWorkloadIdentity. This is only designed for usage by the cache. +func (b *WorkloadIdentityService) UpsertWorkloadIdentity( + ctx context.Context, resource *workloadidentityv1pb.WorkloadIdentity, +) (*workloadidentityv1pb.WorkloadIdentity, error) { + upserted, err := b.service.UpsertResource(ctx, resource) + return upserted, trace.Wrap(err) +} + +// UpdateWorkloadIdentity updates a specific WorkloadIdentity. +func (b *WorkloadIdentityService) UpdateWorkloadIdentity( + ctx context.Context, resource *workloadidentityv1pb.WorkloadIdentity, +) (*workloadidentityv1pb.WorkloadIdentity, error) { + updated, err := b.service.ConditionalUpdateResource(ctx, resource) + return updated, trace.Wrap(err) +} diff --git a/lib/services/local/workload_identity_test.go b/lib/services/local/workload_identity_test.go new file mode 100644 index 0000000000000..1aa4a046eb79f --- /dev/null +++ b/lib/services/local/workload_identity_test.go @@ -0,0 +1,17 @@ +// 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 local diff --git a/lib/services/workload_identity.go b/lib/services/workload_identity.go new file mode 100644 index 0000000000000..1bb2a13d94337 --- /dev/null +++ b/lib/services/workload_identity.go @@ -0,0 +1,91 @@ +// 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 services + +import ( + "context" + + "github.com/gravitational/trace" + + machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/api/types" +) + +// WorkloadIdentities is an interface over the WorkloadIdentities service. This +// interface may also be implemented by a client to allow remote and local +// consumers to access the resource in a similar way. +type WorkloadIdentities interface { + // GetWorkloadIdentity gets a SPIFFE Federation by name. + GetWorkloadIdentity( + ctx context.Context, name string, + ) (*workloadidentityv1pb.WorkloadIdentity, error) + // ListWorkloadIdentities lists all WorkloadIdentities using Google style + // pagination. + ListWorkloadIdentities( + ctx context.Context, pageSize int, lastToken string, + ) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) + // CreateWorkloadIdentity creates a new WorkloadIdentity. + CreateWorkloadIdentity( + ctx context.Context, workloadIdentity *workloadidentityv1pb.WorkloadIdentity, + ) (*workloadidentityv1pb.WorkloadIdentity, error) + // DeleteWorkloadIdentity deletes a SPIFFE Federation by name. + DeleteWorkloadIdentity(ctx context.Context, name string) error + // UpdateWorkloadIdentity updates a WorkloadIdentity. It will not act if the + // resource is not found or where the revision does not match. + UpdateWorkloadIdentity( + ctx context.Context, workloadIdentity *workloadidentityv1pb.WorkloadIdentity, + ) (*workloadidentityv1pb.WorkloadIdentity, error) +} + +// MarshalWorkloadIdentity marshals the WorkloadIdentity object into a JSON byte +// array. +func MarshalWorkloadIdentity( + object *workloadidentityv1pb.WorkloadIdentity, opts ...MarshalOption, +) ([]byte, error) { + return MarshalProtoResource(object, opts...) +} + +// UnmarshalWorkloadIdentity unmarshals the WorkloadIdentity object from a +// JSON byte array. +func UnmarshalWorkloadIdentity( + data []byte, opts ...MarshalOption, +) (*workloadidentityv1pb.WorkloadIdentity, error) { + return UnmarshalProtoResource[*workloadidentityv1pb.WorkloadIdentity](data, opts...) +} + +// ValidateWorkloadIdentity validates the WorkloadIdentity object. This is +// performed prior to writing to the backend. +func ValidateWorkloadIdentity(s *workloadidentityv1pb.WorkloadIdentity) error { + switch { + case s == nil: + return trace.BadParameter("object cannot be nil") + case s.Version != types.V1: + return trace.BadParameter("version: only %q is supported", types.V1) + case s.Kind != types.KindWorkloadIdentity: + return trace.BadParameter("kind: must be %q", types.KindWorkloadIdentity) + case s.Metadata == nil: + return trace.BadParameter("metadata: is required") + case s.Metadata.Name == "": + return trace.BadParameter("metadata.name: is required") + case s.Spec == nil: + return trace.BadParameter("spec: is required") + } + + // TODO: More validation here!! + return nil +} diff --git a/lib/services/workload_identity_test.go b/lib/services/workload_identity_test.go new file mode 100644 index 0000000000000..15ad8316c6bd2 --- /dev/null +++ b/lib/services/workload_identity_test.go @@ -0,0 +1,17 @@ +// 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 services