diff --git a/api/types/constants.go b/api/types/constants.go index 507b7af600897..1915173c7c59a 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -311,10 +311,17 @@ const ( // KindSessionRecordingConfig is the resource for session recording configuration. KindSessionRecordingConfig = "session_recording_config" + // KindRecordingEncryption is the collection of active session recording encryption keys. + KindRecordingEncryption = "recording_encryption" + // MetaNameSessionRecordingConfig is the exact name of the singleton resource for // session recording configuration. MetaNameSessionRecordingConfig = "session-recording-config" + // MetaNameRecordingEncryption is the exact name of the singleton resource for + // session recording configuration. + MetaNameRecordingEncryption = "recording-encryption" + // KindExternalAuditStorage the resource kind for External Audit Storage // configuration. KindExternalAuditStorage = "external_audit_storage" diff --git a/api/types/resource.go b/api/types/resource.go index 544f7b1f92b8c..571572ef7235a 100644 --- a/api/types/resource.go +++ b/api/types/resource.go @@ -746,7 +746,7 @@ func GetRevision(v any) (string, error) { case Resource: return r.GetRevision(), nil case ResourceMetadata: - return r.GetMetadata().Revision, nil + return r.GetMetadata().GetRevision(), nil } return "", trace.BadParameter("unable to determine revision from resource of type %T", v) } diff --git a/lib/services/local/events.go b/lib/services/local/events.go index c667f3e50a3d5..8a5555f521807 100644 --- a/lib/services/local/events.go +++ b/lib/services/local/events.go @@ -258,6 +258,8 @@ func (e *EventsService) NewWatcher(ctx context.Context, watch types.Watch) (type parser = newWorkloadIdentityX509RevocationParser() case types.KindHealthCheckConfig: parser = newHealthCheckConfigParser() + case types.KindRecordingEncryption: + parser = newRecordingEncryptionParser() default: if watch.AllowPartialSuccess { continue diff --git a/lib/services/local/recording_encryption.go b/lib/services/local/recording_encryption.go new file mode 100644 index 0000000000000..a98ffbca37a63 --- /dev/null +++ b/lib/services/local/recording_encryption.go @@ -0,0 +1,128 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package local + +import ( + "context" + + "github.com/gravitational/trace" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + recordingencryptionv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingencryption/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 ( + recordingEncryptionPrefix = "recording_encryption" +) + +// RecordingEncryptionService exposes backend functionality for working with the +// cluster's RecordingEncryption resource. +type RecordingEncryptionService struct { + encryption *generic.ServiceWrapper[*recordingencryptionv1.RecordingEncryption] +} + +var _ services.RecordingEncryption = (*RecordingEncryptionService)(nil) + +// NewRecordingEncryptionService creates a new RecordingEncryptionService. +func NewRecordingEncryptionService(b backend.Backend) (*RecordingEncryptionService, error) { + const pageLimit = 100 + encryption, err := generic.NewServiceWrapper(generic.ServiceConfig[*recordingencryptionv1.RecordingEncryption]{ + Backend: b, + PageLimit: pageLimit, + ResourceKind: types.KindRecordingEncryption, + BackendPrefix: backend.NewKey(recordingEncryptionPrefix), + MarshalFunc: services.MarshalProtoResource[*recordingencryptionv1.RecordingEncryption], + UnmarshalFunc: services.UnmarshalProtoResource[*recordingencryptionv1.RecordingEncryption], + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return &RecordingEncryptionService{ + encryption: encryption, + }, nil +} + +// CreateRecordingEncryption creates a new RecordingEncryption in the backend. +func (s *RecordingEncryptionService) CreateRecordingEncryption(ctx context.Context, encryption *recordingencryptionv1.RecordingEncryption) (*recordingencryptionv1.RecordingEncryption, error) { + if encryption.Metadata == nil { + encryption.Metadata = &headerv1.Metadata{} + } + encryption.Metadata.Name = types.MetaNameRecordingEncryption + created, err := s.encryption.CreateResource(ctx, encryption) + return created, trace.Wrap(err) +} + +// UpdateRecordingEncryption replaces the RecordingEncryption resource with the given one. +func (s *RecordingEncryptionService) UpdateRecordingEncryption(ctx context.Context, encryption *recordingencryptionv1.RecordingEncryption) (*recordingencryptionv1.RecordingEncryption, error) { + if encryption.Metadata == nil { + encryption.Metadata = &headerv1.Metadata{} + } + encryption.Metadata.Name = types.MetaNameRecordingEncryption + updated, err := s.encryption.ConditionalUpdateResource(ctx, encryption) + return updated, trace.Wrap(err) +} + +// DeleteRecordingEncryption removes the RecordingEncryption from the cluster. +func (s *RecordingEncryptionService) DeleteRecordingEncryption(ctx context.Context) error { + return trace.Wrap(s.encryption.DeleteResource(ctx, types.MetaNameRecordingEncryption)) +} + +// GetRecordingEncryption retrieves the RecordingEncryption for the cluster. +func (s *RecordingEncryptionService) GetRecordingEncryption(ctx context.Context) (*recordingencryptionv1.RecordingEncryption, error) { + encryption, err := s.encryption.GetResource(ctx, types.MetaNameRecordingEncryption) + return encryption, trace.Wrap(err) +} + +type recordingEncryptionParser struct { + baseParser +} + +func newRecordingEncryptionParser() *recordingEncryptionParser { + return &recordingEncryptionParser{ + baseParser: newBaseParser(backend.NewKey(recordingEncryptionPrefix)), + } +} + +func (p *recordingEncryptionParser) parse(event backend.Event) (types.Resource, error) { + switch event.Type { + case types.OpPut: + resource, err := services.UnmarshalProtoResource[*recordingencryptionv1.RecordingEncryption]( + event.Item.Value, + services.WithExpires(event.Item.Expires), + services.WithRevision(event.Item.Revision), + ) + if err != nil { + return nil, trace.Wrap(err, "unmarshalling resource from event") + } + return types.Resource153ToLegacy(resource), nil + case types.OpDelete: + return &types.ResourceHeader{ + Kind: types.KindRecordingEncryption, + Version: types.V1, + Metadata: types.Metadata{ + Name: types.MetaNameRecordingEncryption, + }, + }, nil + default: + return nil, trace.BadParameter("event %v is not supported", event.Type) + } +} diff --git a/lib/services/local/recording_encryption_test.go b/lib/services/local/recording_encryption_test.go new file mode 100644 index 0000000000000..0f5420f02d1e1 --- /dev/null +++ b/lib/services/local/recording_encryption_test.go @@ -0,0 +1,88 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package local + +import ( + "context" + "crypto" + "testing" + + "github.com/stretchr/testify/require" + + pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingencryption/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/backend" + "github.com/gravitational/teleport/lib/backend/memory" +) + +func TestRecordingEncryption(t *testing.T) { + bk, err := memory.New(memory.Config{}) + require.NoError(t, err) + service, err := NewRecordingEncryptionService(backend.NewSanitizer(bk)) + require.NoError(t, err) + + ctx := context.Background() + + initialEncryption := pb.RecordingEncryption{ + Spec: &pb.RecordingEncryptionSpec{ + ActiveKeys: nil, + }, + } + + // get should fail when there's no recording encryption + _, err = service.GetRecordingEncryption(ctx) + require.Error(t, err) + + created, err := service.CreateRecordingEncryption(ctx, &initialEncryption) + require.NoError(t, err) + + encryption, err := service.GetRecordingEncryption(ctx) + require.NoError(t, err) + + require.Empty(t, created.Spec.ActiveKeys) + require.Empty(t, encryption.Spec.ActiveKeys) + + encryption.Spec.ActiveKeys = []*pb.WrappedKey{ + { + RecordingEncryptionPair: &types.EncryptionKeyPair{ + PrivateKey: []byte("recording encryption private"), + PublicKey: []byte("recording encryption public"), + Hash: 0, + }, + KeyEncryptionPair: &types.EncryptionKeyPair{ + PrivateKey: []byte("key encryption private"), + PublicKey: []byte("key encryption public"), + Hash: uint32(crypto.SHA256), + }, + }, + } + + updated, err := service.UpdateRecordingEncryption(ctx, encryption) + require.NoError(t, err) + require.Len(t, updated.Spec.ActiveKeys, 1) + require.EqualExportedValues(t, encryption.Spec.ActiveKeys[0], updated.Spec.ActiveKeys[0]) + + encryption, err = service.GetRecordingEncryption(ctx) + require.NoError(t, err) + require.Len(t, encryption.Spec.ActiveKeys, 1) + require.EqualExportedValues(t, updated.Spec.ActiveKeys[0], encryption.Spec.ActiveKeys[0]) + + err = service.DeleteRecordingEncryption(ctx) + require.NoError(t, err) + _, err = service.GetRecordingEncryption(ctx) + require.Error(t, err) +} diff --git a/lib/services/recording_encryption.go b/lib/services/recording_encryption.go new file mode 100644 index 0000000000000..49216df69749a --- /dev/null +++ b/lib/services/recording_encryption.go @@ -0,0 +1,36 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package services + +import ( + "context" + + recordingencryptionv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/recordingencryption/v1" +) + +// RecordingEncryption handles CRUD operations for the RecordingEncryption resource. +type RecordingEncryption interface { + // CreateRecordingEncryption creates a new RecordingEncryption in the backend if one + // does not already exist. + CreateRecordingEncryption(ctx context.Context, encryption *recordingencryptionv1.RecordingEncryption) (*recordingencryptionv1.RecordingEncryption, error) + // UpdateRecordingEncryption replaces the RecordingEncryption resource with the given one. + UpdateRecordingEncryption(ctx context.Context, encryption *recordingencryptionv1.RecordingEncryption) (*recordingencryptionv1.RecordingEncryption, error) + // DeleteRecordingEncryption removes the RecordingEncryption from the cluster. + DeleteRecordingEncryption(ctx context.Context) error + // GetRecordingEncryption retrieves the RecordingEncryption for the cluster. + GetRecordingEncryption(ctx context.Context) (*recordingencryptionv1.RecordingEncryption, error) +}