From 63d25ee5ef5226ff3c79f8c9364103fa256b2126 Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Thu, 11 Jul 2024 09:40:24 +0100 Subject: [PATCH] [sec_scan][5] add secrets backend service (#43543) * [sec_scan][5] add secrets backend service This PR implements the backend service to support storing `authorized_keys` and `private_keys` into Teleport backend. Part of https://github.com/gravitational/access-graph/issues/637 Signed-off-by: Tiago Silva * handle feedback * handle nits --------- Signed-off-by: Tiago Silva --- api/types/accessgraph/authorized_key.go | 90 +++++++ api/types/accessgraph/authorized_key_test.go | 90 +++++++ api/types/accessgraph/private_key.go | 108 ++++++++ api/types/accessgraph/private_key_test.go | 119 +++++++++ api/types/constants.go | 8 + lib/services/access_graph.go | 68 +++++ lib/services/local/access_graph.go | 164 ++++++++++++ lib/services/local/access_graph_test.go | 247 +++++++++++++++++++ 8 files changed, 894 insertions(+) create mode 100644 api/types/accessgraph/authorized_key.go create mode 100644 api/types/accessgraph/authorized_key_test.go create mode 100644 api/types/accessgraph/private_key.go create mode 100644 api/types/accessgraph/private_key_test.go create mode 100644 lib/services/access_graph.go create mode 100644 lib/services/local/access_graph.go create mode 100644 lib/services/local/access_graph_test.go diff --git a/api/types/accessgraph/authorized_key.go b/api/types/accessgraph/authorized_key.go new file mode 100644 index 0000000000000..5532f39bc2775 --- /dev/null +++ b/api/types/accessgraph/authorized_key.go @@ -0,0 +1,90 @@ +/* +Copyright 2024 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package accessgraph + +import ( + "time" + + "github.com/gravitational/trace" + "google.golang.org/protobuf/types/known/timestamppb" + + accessgraphv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + "github.com/gravitational/teleport/api/types" +) + +const ( + authorizedKeyDefaultKeyTTL = 8 * time.Hour +) + +// NewAuthorizedKey creates a new SSH authorized key resource. +func NewAuthorizedKey(spec *accessgraphv1pb.AuthorizedKeySpec) (*accessgraphv1pb.AuthorizedKey, error) { + name := authKeyHashNameKey(spec) + authKey := &accessgraphv1pb.AuthorizedKey{ + Kind: types.KindAccessGraphSecretAuthorizedKey, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: name, + Expires: timestamppb.New( + time.Now().Add(authorizedKeyDefaultKeyTTL), + ), + }, + Spec: spec, + } + if err := ValidateAuthorizedKey(authKey); err != nil { + return nil, trace.Wrap(err) + } + + return authKey, nil +} + +// ValidateAuthorizedKey checks that required parameters are set +// for the specified AuthorizedKey +func ValidateAuthorizedKey(k *accessgraphv1pb.AuthorizedKey) error { + if k == nil { + return trace.BadParameter("AuthorizedKey is nil") + } + if k.Metadata == nil { + return trace.BadParameter("Metadata is nil") + } + if k.Spec == nil { + return trace.BadParameter("Spec is nil") + } + + if k.Spec.HostId == "" { + return trace.BadParameter("HostId is unset") + } + if k.Spec.HostUser == "" { + return trace.BadParameter("HostUser is unset") + } + if k.Spec.KeyFingerprint == "" { + return trace.BadParameter("KeyFingerprint is unset") + } + + if k.Metadata.Name == "" { + return trace.BadParameter("Name is unset") + } + if k.Metadata.Name != authKeyHashNameKey(k.Spec) { + return trace.BadParameter("Name must be derived from the key fields") + } + + return nil +} + +func authKeyHashNameKey(k *accessgraphv1pb.AuthorizedKeySpec) string { + return hashComp(k.HostId, k.HostUser, k.KeyFingerprint) +} diff --git a/api/types/accessgraph/authorized_key_test.go b/api/types/accessgraph/authorized_key_test.go new file mode 100644 index 0000000000000..d28d72ebd3e7e --- /dev/null +++ b/api/types/accessgraph/authorized_key_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2024 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package accessgraph + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + + accessgraphv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" +) + +func TestAuthorizedKey(t *testing.T) { + tests := []struct { + name string + spec *accessgraphv1pb.AuthorizedKeySpec + errValidation require.ErrorAssertionFunc + }{ + { + name: "valid", + spec: &accessgraphv1pb.AuthorizedKeySpec{ + HostId: uuid.New().String(), + KeyFingerprint: "fingerprint", + HostUser: "user", + }, + errValidation: require.NoError, + }, + { + name: "missing fingerprint", + spec: &accessgraphv1pb.AuthorizedKeySpec{ + HostId: uuid.New().String(), + KeyFingerprint: "", + HostUser: "user", + }, + errValidation: func(t require.TestingT, err error, i ...any) { + require.ErrorContains(t, err, "KeyFingerprint is unset") + }, + }, + { + name: "missing user", + spec: &accessgraphv1pb.AuthorizedKeySpec{ + HostId: uuid.New().String(), + KeyFingerprint: "fingerprint", + HostUser: "", + }, + errValidation: func(t require.TestingT, err error, i ...any) { + require.ErrorContains(t, err, "HostUser is unset") + }, + }, + { + name: "missing HostID", + spec: &accessgraphv1pb.AuthorizedKeySpec{ + KeyFingerprint: "fingerprint", + HostUser: "user", + }, + errValidation: func(t require.TestingT, err error, i ...any) { + require.ErrorContains(t, err, "HostId is unset") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + privKey, err := NewAuthorizedKey(tt.spec) + tt.errValidation(t, err) + if err != nil { + return + } + require.NotEmpty(t, privKey.Metadata.Name) + require.Empty(t, cmp.Diff(tt.spec, privKey.Spec, protocmp.Transform())) + + }) + } +} diff --git a/api/types/accessgraph/private_key.go b/api/types/accessgraph/private_key.go new file mode 100644 index 0000000000000..57e0874ed040a --- /dev/null +++ b/api/types/accessgraph/private_key.go @@ -0,0 +1,108 @@ +/* +Copyright 2024 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package accessgraph + +import ( + "crypto/sha256" + "encoding/hex" + + "github.com/gravitational/trace" + + accessgraphv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + "github.com/gravitational/teleport/api/types" +) + +// NewPrivateKey creates a new SSH Private key resource with a generated name based on the spec. +func NewPrivateKey(spec *accessgraphv1pb.PrivateKeySpec) (*accessgraphv1pb.PrivateKey, error) { + name := privKeyHashNameKey(spec) + v, err := NewPrivateKeyWithName(name, spec) + + return v, trace.Wrap(err) +} + +// NewPrivateKeyWithName creates a new SSH Private key resource. +func NewPrivateKeyWithName(name string, spec *accessgraphv1pb.PrivateKeySpec) (*accessgraphv1pb.PrivateKey, error) { + privKey := &accessgraphv1pb.PrivateKey{ + Kind: types.KindAccessGraphSecretPrivateKey, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: name, + }, + Spec: spec, + } + if err := ValidatePrivateKey(privKey); err != nil { + return nil, trace.Wrap(err) + } + + return privKey, nil +} + +// ValidatePrivateKey checks that required parameters are set +// for the specified PrivateKey +func ValidatePrivateKey(k *accessgraphv1pb.PrivateKey) error { + if k == nil { + return trace.BadParameter("PrivateKey is nil") + } + if k.Metadata == nil { + return trace.BadParameter("Metadata is nil") + } + if k.Spec == nil { + return trace.BadParameter("Spec is nil") + } + + if k.Kind != types.KindAccessGraphSecretPrivateKey { + return trace.BadParameter("Kind is invalid") + } + + if k.Version != types.V1 { + return trace.BadParameter("Version is invalid") + } + + switch k.Spec.PublicKeyMode { + case accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PROTECTED, + accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PUB_FILE, + accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED: + default: + return trace.BadParameter("PublicKeyMode is invalid") + } + + if k.Spec.DeviceId == "" { + return trace.BadParameter("DeviceId is unset") + } + if k.Spec.PublicKeyFingerprint == "" && k.Spec.PublicKeyMode != accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PROTECTED { + return trace.BadParameter("PublicKeyFingerprint is unset") + } + + if k.Metadata.Name == "" { + return trace.BadParameter("Name is unset") + } + + return nil +} + +func privKeyHashNameKey(k *accessgraphv1pb.PrivateKeySpec) string { + return hashComp(k.DeviceId, k.PublicKeyFingerprint) +} + +func hashComp(values ...string) string { + h := sha256.New() + for _, value := range values { + h.Write([]byte(value)) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/api/types/accessgraph/private_key_test.go b/api/types/accessgraph/private_key_test.go new file mode 100644 index 0000000000000..c1c38bd21c49f --- /dev/null +++ b/api/types/accessgraph/private_key_test.go @@ -0,0 +1,119 @@ +/* +Copyright 2024 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package accessgraph + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + + accessgraphv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" +) + +func TestPrivateKey(t *testing.T) { + tests := []struct { + name string + spec *accessgraphv1pb.PrivateKeySpec + errValidation require.ErrorAssertionFunc + }{ + { + name: "valid derived", + spec: &accessgraphv1pb.PrivateKeySpec{ + DeviceId: uuid.New().String(), + PublicKeyFingerprint: "fingerprint", + PublicKeyMode: accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED, + }, + errValidation: require.NoError, + }, + { + name: "valid file", + spec: &accessgraphv1pb.PrivateKeySpec{ + DeviceId: uuid.New().String(), + PublicKeyFingerprint: "fingerprint", + PublicKeyMode: accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PUB_FILE, + }, + errValidation: require.NoError, + }, + { + name: "missing fingerprint derived", + spec: &accessgraphv1pb.PrivateKeySpec{ + DeviceId: uuid.New().String(), + PublicKeyFingerprint: "", + PublicKeyMode: accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED, + }, + errValidation: func(t require.TestingT, err error, i ...any) { + require.ErrorContains(t, err, "PublicKeyFingerprint is unset") + }, + }, + { + name: "missing fingerprint file", + spec: &accessgraphv1pb.PrivateKeySpec{ + DeviceId: uuid.New().String(), + PublicKeyFingerprint: "", + PublicKeyMode: accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PUB_FILE, + }, + errValidation: func(t require.TestingT, err error, i ...any) { + require.ErrorContains(t, err, "PublicKeyFingerprint is unset") + }, + }, + { + name: "valid protected", + spec: &accessgraphv1pb.PrivateKeySpec{ + DeviceId: uuid.New().String(), + PublicKeyFingerprint: "", /* empty fingerprint */ + PublicKeyMode: accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PROTECTED, + }, + errValidation: require.NoError, + }, + { + name: "invalid public key ode", + spec: &accessgraphv1pb.PrivateKeySpec{ + DeviceId: uuid.New().String(), + PublicKeyFingerprint: "fingerprint", + PublicKeyMode: 500, + }, + errValidation: func(t require.TestingT, err error, i ...any) { + require.ErrorContains(t, err, "PublicKeyMode is invalid") + }, + }, + { + name: "missing DeviceId", + spec: &accessgraphv1pb.PrivateKeySpec{ + PublicKeyFingerprint: "fingerprint", + PublicKeyMode: accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PROTECTED, + }, + errValidation: func(t require.TestingT, err error, i ...any) { + require.ErrorContains(t, err, "DeviceId is unset") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + privKey, err := NewPrivateKey(tt.spec) + tt.errValidation(t, err) + if err != nil { + return + } + require.NotEmpty(t, privKey.Metadata.Name) + require.Empty(t, cmp.Diff(tt.spec, privKey.Spec, protocmp.Transform())) + + }) + } +} diff --git a/api/types/constants.go b/api/types/constants.go index effe5f175a32d..3a0eff165449c 100644 --- a/api/types/constants.go +++ b/api/types/constants.go @@ -519,6 +519,14 @@ const ( // KindUserNotificationState is a resource which tracks whether a user has clicked on or dismissed a notification. KindUserNotificationState = "user_notification_state" + // KindAccessGraphSecretAuthorizedKey is a authorized key entry found in + // a Teleport SSH node type. + KindAccessGraphSecretAuthorizedKey = "access_graph_authorized_key" + + // KindAccessGraphSecretPrivateKey is a private key entry found in + // a managed device. + KindAccessGraphSecretPrivateKey = "access_graph_private_key" + // KindVnetConfig is a resource which holds cluster-wide configuration for VNet. KindVnetConfig = "vnet_config" diff --git a/lib/services/access_graph.go b/lib/services/access_graph.go new file mode 100644 index 0000000000000..6ff41221b5a37 --- /dev/null +++ b/lib/services/access_graph.go @@ -0,0 +1,68 @@ +/* + * Teleport + * Copyright (C) 2023 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 ( + "github.com/gravitational/trace" + + accessgraphsecretspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + "github.com/gravitational/teleport/api/types/accessgraph" +) + +// MarshalAccessGraphAuthorizedKey marshals a [accessgraphsecretspb.AuthorizedKey] resource to JSON. +func MarshalAccessGraphAuthorizedKey(in *accessgraphsecretspb.AuthorizedKey, opts ...MarshalOption) ([]byte, error) { + if err := accessgraph.ValidateAuthorizedKey(in); err != nil { + return nil, trace.Wrap(err) + } + + return MarshalProtoResource(in, opts...) +} + +// UnmarshalAccessGraphAuthorizedKey unmarshals a [accessgraphsecretspb.AuthorizedKey] resource from JSON. +func UnmarshalAccessGraphAuthorizedKey(data []byte, opts ...MarshalOption) (*accessgraphsecretspb.AuthorizedKey, error) { + out, err := UnmarshalProtoResource[*accessgraphsecretspb.AuthorizedKey](data, opts...) + if err != nil { + return nil, trace.Wrap(err) + } + if err := accessgraph.ValidateAuthorizedKey(out); err != nil { + return nil, trace.Wrap(err) + } + return out, nil +} + +// MarshalAccessGraphPrivateKey marshals a [accessgraphsecretspb.PrivateKey] resource to JSON. +func MarshalAccessGraphPrivateKey(in *accessgraphsecretspb.PrivateKey, opts ...MarshalOption) ([]byte, error) { + if err := accessgraph.ValidatePrivateKey(in); err != nil { + return nil, trace.Wrap(err) + } + + return MarshalProtoResource(in, opts...) +} + +// UnmarshalAccessGraphPrivateKey unmarshals a [accessgraphsecretspb.PrivateKey] resource from JSON. +func UnmarshalAccessGraphPrivateKey(data []byte, opts ...MarshalOption) (*accessgraphsecretspb.PrivateKey, error) { + out, err := UnmarshalProtoResource[*accessgraphsecretspb.PrivateKey](data, opts...) + if err != nil { + return nil, trace.Wrap(err) + } + if err := accessgraph.ValidatePrivateKey(out); err != nil { + return nil, trace.Wrap(err) + } + return out, nil +} diff --git a/lib/services/local/access_graph.go b/lib/services/local/access_graph.go new file mode 100644 index 0000000000000..4be7ec74ec6c3 --- /dev/null +++ b/lib/services/local/access_graph.go @@ -0,0 +1,164 @@ +/* + * 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" + + accessgraphsecretspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/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 ( + authorizedKeysPrefix = "access_graph_ssh_authorized_keys" + privateKeysPrefix = "access_graph_ssh_private_keys" +) + +// AccessGraphSecretsService manages secrets found on Teleport Nodes and +// enrolled devices. +type AccessGraphSecretsService struct { + authorizedKeysSvc *generic.ServiceWrapper[*accessgraphsecretspb.AuthorizedKey] + privateKeysSvc *generic.ServiceWrapper[*accessgraphsecretspb.PrivateKey] +} + +// NewAccessGraphSecretsService returns a new Access Graph Secrets service. +// This service in Teleport is used to keep track of secrets found in Teleport +// Nodes and on enrolled devices. Currently, it only stores secrets related with +// SSH Keys. Future implementations might extend them. +func NewAccessGraphSecretsService(backend backend.Backend) (*AccessGraphSecretsService, error) { + authorizedKeysSvc, err := generic.NewServiceWrapper( + backend, + types.KindAccessGraphSecretAuthorizedKey, + authorizedKeysPrefix, + services.MarshalAccessGraphAuthorizedKey, + services.UnmarshalAccessGraphAuthorizedKey, + ) + if err != nil { + return nil, trace.Wrap(err) + } + + privateKeysSvc, err := generic.NewServiceWrapper( + backend, + types.KindAccessGraphSecretPrivateKey, + privateKeysPrefix, + services.MarshalAccessGraphPrivateKey, + services.UnmarshalAccessGraphPrivateKey, + ) + if err != nil { + return nil, trace.Wrap(err) + } + + return &AccessGraphSecretsService{ + authorizedKeysSvc: authorizedKeysSvc, + privateKeysSvc: privateKeysSvc, + }, nil +} + +// ListAllAuthorizedKeys lists all authorized keys stored in the backend. +func (k *AccessGraphSecretsService) ListAllAuthorizedKeys(ctx context.Context, pageSize int, pageToken string) ([]*accessgraphsecretspb.AuthorizedKey, string, error) { + out, next, err := k.authorizedKeysSvc.ListResources(ctx, pageSize, pageToken) + if err != nil { + return nil, "", trace.Wrap(err) + } + return out, next, nil +} + +// ListAuthorizedKeysForServer lists all authorized keys for a given hostID. +func (k *AccessGraphSecretsService) ListAuthorizedKeysForServer(ctx context.Context, hostID string, pageSize int, pageToken string) ([]*accessgraphsecretspb.AuthorizedKey, string, error) { + if hostID == "" { + return nil, "", trace.BadParameter("server name is required") + } + svc := k.authorizedKeysSvc.WithPrefix(hostID) + out, next, err := svc.ListResources(ctx, pageSize, pageToken) + if err != nil { + return nil, "", trace.Wrap(err) + } + return out, next, nil +} + +// UpsertAuthorizedKey upserts a new authorized key. +func (k *AccessGraphSecretsService) UpsertAuthorizedKey(ctx context.Context, in *accessgraphsecretspb.AuthorizedKey) (*accessgraphsecretspb.AuthorizedKey, error) { + svc := k.authorizedKeysSvc.WithPrefix(in.Spec.HostId) + out, err := svc.UpsertResource(ctx, in) + if err != nil { + return nil, trace.Wrap(err) + } + + return out, nil +} + +// DeleteAuthorizedKey deletes a specific authorized key. +func (k *AccessGraphSecretsService) DeleteAuthorizedKey(ctx context.Context, hostID, name string) error { + svc := k.authorizedKeysSvc.WithPrefix(hostID) + return trace.Wrap(svc.DeleteResource(ctx, name)) +} + +// DeleteAllAuthorizedKeys deletes all authorized keys. +func (k *AccessGraphSecretsService) DeleteAllAuthorizedKeys(ctx context.Context) error { + return trace.Wrap(k.authorizedKeysSvc.DeleteAllResources(ctx)) +} + +// ListAllPrivateKeys lists all private keys stored in the backend. +func (k *AccessGraphSecretsService) ListAllPrivateKeys(ctx context.Context, pageSize int, pageToken string) ([]*accessgraphsecretspb.PrivateKey, string, error) { + out, next, err := k.privateKeysSvc.ListResources(ctx, pageSize, pageToken) + if err != nil { + return nil, "", trace.Wrap(err) + } + return out, next, nil +} + +// ListPrivateKeysForDevice lists all private keys for a given deviceID. +func (k *AccessGraphSecretsService) ListPrivateKeysForDevice(ctx context.Context, deviceID string, pageSize int, pageToken string) ([]*accessgraphsecretspb.PrivateKey, string, error) { + if deviceID == "" { + return nil, "", trace.BadParameter("server name is required") + } + svc := k.privateKeysSvc.WithPrefix(deviceID) + out, next, err := svc.ListResources(ctx, pageSize, pageToken) + if err != nil { + return nil, "", trace.Wrap(err) + } + return out, next, nil +} + +// UpsertPrivateKey upserts a new private key. +func (k *AccessGraphSecretsService) UpsertPrivateKey(ctx context.Context, in *accessgraphsecretspb.PrivateKey) (*accessgraphsecretspb.PrivateKey, error) { + svc := k.privateKeysSvc.WithPrefix(in.Spec.DeviceId) + out, err := svc.UpsertResource(ctx, in) + if err != nil { + return nil, trace.Wrap(err) + } + + return out, nil +} + +// DeletePrivateKey deletes a specific private key. +func (k *AccessGraphSecretsService) DeletePrivateKey(ctx context.Context, deviceID, name string) error { + svc := k.privateKeysSvc.WithPrefix(deviceID) + return trace.Wrap(svc.DeleteResource(ctx, name)) +} + +// DeleteAllPrivateKeys deletes all private keys. +func (k *AccessGraphSecretsService) DeleteAllPrivateKeys(ctx context.Context) error { + return trace.Wrap(k.privateKeysSvc.DeleteAllResources(ctx)) +} diff --git a/lib/services/local/access_graph_test.go b/lib/services/local/access_graph_test.go new file mode 100644 index 0000000000000..2eda122f8c045 --- /dev/null +++ b/lib/services/local/access_graph_test.go @@ -0,0 +1,247 @@ +/* + * 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" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + + accessgraphsecretspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + "github.com/gravitational/teleport/api/types/accessgraph" + "github.com/gravitational/teleport/lib/backend/memory" +) + +func TestAccessGraphAuthorizedKeys(t *testing.T) { + backend, err := memory.New(memory.Config{}) + require.NoError(t, err) + service, err := NewAccessGraphSecretsService(backend) + require.NoError(t, err) + + ctx := context.TODO() + pageSize := 10 + pageToken := "" + + // Test case 1: Empty list + keys, nextToken, err := service.ListAllAuthorizedKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Empty(t, keys) + require.Empty(t, nextToken) + + // Test case 2: Non-empty list + authorizedKeys := []*accessgraphsecretspb.AuthorizedKeySpec{ + { + HostId: "host1", + HostUser: "user1", + KeyFingerprint: "AAAAB3NzaC1yc2EAAAADAQABAAABAQC...", + }, + { + HostId: "host1", + HostUser: "user2", + KeyFingerprint: "AAAAB3NzaC1yc2EAAAADAQABAAABAQC...", + }, + { + HostId: "host2", + HostUser: "user1", + KeyFingerprint: "AAAAB3NzaC1yc2EAAAADAQABAAABAQC...", + }, + { + HostId: "host2", + HostUser: "user2", + KeyFingerprint: "AAAAB3NzaC1yc2EAAAADAQABAAABAQC...", + }, + } + var authKeys []*accessgraphsecretspb.AuthorizedKey + for _, key := range authorizedKeys { + authKey, err := accessgraph.NewAuthorizedKey(key) + require.NoError(t, err) + _, err = service.UpsertAuthorizedKey(ctx, authKey) + require.NoError(t, err) + authKeys = append(authKeys, authKey) + } + + keys, nextToken, err = service.ListAllAuthorizedKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Empty(t, cmp.Diff(authKeys, keys, + protocmp.Transform(), + cmpopts.SortSlices(func(a, b *accessgraphsecretspb.AuthorizedKey) bool { + return a.Metadata.Name < b.Metadata.Name + }))) + require.Empty(t, nextToken) + + // Test case 3: Pagination + pageSize = 2 + pageToken = "" + keys, nextToken, err = service.ListAllAuthorizedKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, pageSize) + require.NotEmpty(t, nextToken) + + pageToken = nextToken + keys, nextToken, err = service.ListAllAuthorizedKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, pageSize) + require.Empty(t, nextToken) + + // Test case 4: List authorized keys for server + pageToken = "" + keysHost1, nextToken, err := service.ListAuthorizedKeysForServer(ctx, "host1", pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, 2) + require.Empty(t, nextToken) + keysHost2, nextToken, err := service.ListAuthorizedKeysForServer(ctx, "host2", pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, 2) + require.Empty(t, nextToken) + require.NotEqual(t, keysHost1, keysHost2) + + // Test case 5: List authorized keys for server with pagination + pageToken = "" + pageSize = 1 + keys, nextToken, err = service.ListAuthorizedKeysForServer(ctx, "host1", pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, 1) + require.NotEmpty(t, nextToken) + + pageToken = nextToken + keys, nextToken, err = service.ListAuthorizedKeysForServer(ctx, "host1", pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, 1) + require.Empty(t, nextToken) + + // Test case 6: Delete all + err = service.DeleteAllAuthorizedKeys(ctx) + require.NoError(t, err) + keys, nextToken, err = service.ListAllAuthorizedKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Empty(t, keys) + require.Empty(t, nextToken) +} + +func TestAccessGraphPrivateKeys(t *testing.T) { + backend, err := memory.New(memory.Config{}) + require.NoError(t, err) + service, err := NewAccessGraphSecretsService(backend) + require.NoError(t, err) + + ctx := context.TODO() + pageSize := 10 + pageToken := "" + + // Test case 1: Empty list + keys, nextToken, err := service.ListAllPrivateKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Empty(t, keys) + require.Empty(t, nextToken) + + // Test case 2: Non-empty list + privateKeysSpec := []*accessgraphsecretspb.PrivateKeySpec{ + { + DeviceId: "device1", + PublicKeyMode: accessgraphsecretspb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED, + PublicKeyFingerprint: "AAAAB3NzaC1yc2EAAAADAQABAAABAQC...", + }, + { + DeviceId: "device1", + PublicKeyMode: accessgraphsecretspb.PublicKeyMode_PUBLIC_KEY_MODE_PUB_FILE, + PublicKeyFingerprint: "AAAAB3NzaC1yc2EAAAADAQABAAABAQC...", + }, + { + DeviceId: "device2", + PublicKeyMode: accessgraphsecretspb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED, + PublicKeyFingerprint: "AAAAB3NzaC1yc2EAAAADAQABAAABAQC...", + }, + { + DeviceId: "device2", + PublicKeyMode: accessgraphsecretspb.PublicKeyMode_PUBLIC_KEY_MODE_PUB_FILE, + PublicKeyFingerprint: "AAAAB3NzaC1yc2EAAAADAQABAAABAQC...", + }, + } + var authKeys []*accessgraphsecretspb.PrivateKey + for _, key := range privateKeysSpec { + name := uuid.New().String() + prvKey, err := accessgraph.NewPrivateKeyWithName(name, key) + require.NoError(t, err) + _, err = service.UpsertPrivateKey(ctx, prvKey) + require.NoError(t, err) + authKeys = append(authKeys, prvKey) + } + + keys, nextToken, err = service.ListAllPrivateKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Empty(t, cmp.Diff(authKeys, keys, + protocmp.Transform(), + cmpopts.SortSlices(func(a, b *accessgraphsecretspb.PrivateKey) bool { + return a.Metadata.Name < b.Metadata.Name + }))) + require.Empty(t, nextToken) + + // Test case 3: Pagination + pageSize = 2 + pageToken = "" + keys, nextToken, err = service.ListAllPrivateKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, pageSize) + require.NotEmpty(t, nextToken) + + pageToken = nextToken + keys, nextToken, err = service.ListAllPrivateKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, pageSize) + require.Empty(t, nextToken) + + // Test case 4: List private keys for device + pageToken = "" + keysHost1, nextToken, err := service.ListPrivateKeysForDevice(ctx, "device1", pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, 2) + require.Empty(t, nextToken) + keysHost2, nextToken, err := service.ListPrivateKeysForDevice(ctx, "device2", pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, 2) + require.Empty(t, nextToken) + require.NotEqual(t, keysHost1, keysHost2) + + // Test case 5: List private keys for device with pagination + pageToken = "" + pageSize = 1 + keys, nextToken, err = service.ListPrivateKeysForDevice(ctx, "device1", pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, 1) + require.NotEmpty(t, nextToken) + + pageToken = nextToken + keys, nextToken, err = service.ListPrivateKeysForDevice(ctx, "device1", pageSize, pageToken) + require.NoError(t, err) + require.Len(t, keys, 1) + require.Empty(t, nextToken) + + // Test case 6: Delete all + err = service.DeleteAllPrivateKeys(ctx) + require.NoError(t, err) + keys, nextToken, err = service.ListAllPrivateKeys(ctx, pageSize, pageToken) + require.NoError(t, err) + require.Empty(t, keys) + require.Empty(t, nextToken) +}