mirror of https://github.com/grafana/grafana
SecretsManager: Introduce keeper store (#105557)
* SecretsManager: Introduce secret database wrapper Co-authored-by: PoorlyDefinedBehaviour <brunotj2015@hotmail.com> Co-authored-by: Leandro Deveikis <leandro.deveikis@gmail.com> Co-authored-by: Dana Axinte <53751979+dana-axinte@users.noreply.github.com> Co-authored-by: Matheus Macabu <macabu@users.noreply.github.com> * SecretsManager: Introduce db migrator with keeper table Co-authored-by: PoorlyDefinedBehaviour <brunotj2015@hotmail.com> Co-authored-by: Leandro Deveikis <leandro.deveikis@gmail.com> Co-authored-by: Dana Axinte <53751979+dana-axinte@users.noreply.github.com> Co-authored-by: Matheus Macabu <macabu@users.noreply.github.com> * SecretsManager: Introduce keeper store Co-authored-by: Leandro Deveikis <leandro.deveikis@gmail.com> Co-authored-by: Dana Axinte <53751979+dana-axinte@users.noreply.github.com> Co-authored-by: Matheus Macabu <macabu@users.noreply.github.com> * new line * without query listByNameSecureValue * remove unused extractSecureValues for now * SecretsManager: Add keeper integration tests Co-authored-by: Leandro Deveikis <leandro.deveikis@gmail.com> Co-authored-by: Dana Axinte <53751979+dana-axinte@users.noreply.github.com> Co-authored-by: Matheus Macabu <macabu@users.noreply.github.com> --------- Co-authored-by: PoorlyDefinedBehaviour <brunotj2015@hotmail.com> Co-authored-by: Leandro Deveikis <leandro.deveikis@gmail.com> Co-authored-by: Matheus Macabu <macabu@users.noreply.github.com>pull/104626/head
parent
c5de567c8c
commit
7f2923d4ed
@ -0,0 +1,27 @@ |
||||
INSERT INTO {{ .Ident "secret_keeper" }} ( |
||||
{{ .Ident "guid" }}, |
||||
{{ .Ident "name" }}, |
||||
{{ .Ident "namespace" }}, |
||||
{{ .Ident "annotations" }}, |
||||
{{ .Ident "labels" }}, |
||||
{{ .Ident "created" }}, |
||||
{{ .Ident "created_by" }}, |
||||
{{ .Ident "updated" }}, |
||||
{{ .Ident "updated_by" }}, |
||||
{{ .Ident "description" }}, |
||||
{{ .Ident "type" }}, |
||||
{{ .Ident "payload" }} |
||||
) VALUES ( |
||||
{{ .Arg .Row.GUID }}, |
||||
{{ .Arg .Row.Name }}, |
||||
{{ .Arg .Row.Namespace }}, |
||||
{{ .Arg .Row.Annotations }}, |
||||
{{ .Arg .Row.Labels }}, |
||||
{{ .Arg .Row.Created }}, |
||||
{{ .Arg .Row.CreatedBy }}, |
||||
{{ .Arg .Row.Updated }}, |
||||
{{ .Arg .Row.UpdatedBy }}, |
||||
{{ .Arg .Row.Description }}, |
||||
{{ .Arg .Row.Type }}, |
||||
{{ .Arg .Row.Payload }} |
||||
); |
@ -0,0 +1,4 @@ |
||||
DELETE FROM {{ .Ident "secret_keeper" }} |
||||
WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND |
||||
{{ .Ident "name" }} = {{ .Arg .Name }} |
||||
; |
@ -0,0 +1,18 @@ |
||||
SELECT |
||||
{{ .Ident "guid" }}, |
||||
{{ .Ident "name" }}, |
||||
{{ .Ident "namespace" }}, |
||||
{{ .Ident "annotations" }}, |
||||
{{ .Ident "labels" }}, |
||||
{{ .Ident "created" }}, |
||||
{{ .Ident "created_by" }}, |
||||
{{ .Ident "updated" }}, |
||||
{{ .Ident "updated_by" }}, |
||||
{{ .Ident "description" }}, |
||||
{{ .Ident "type" }}, |
||||
{{ .Ident "payload" }} |
||||
FROM |
||||
{{ .Ident "secret_keeper" }} |
||||
WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} |
||||
ORDER BY {{ .Ident "updated" }} DESC |
||||
; |
@ -0,0 +1,10 @@ |
||||
{{/* this query is used to validate the keeper update or creation */}} |
||||
|
||||
SELECT |
||||
{{ .Ident "name" }} |
||||
FROM |
||||
{{ .Ident "secret_keeper" }} |
||||
WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND |
||||
{{ .Ident "name" }} IN ({{ .ArgList .KeeperNames }}) |
||||
{{ .SelectFor "UPDATE" }} |
||||
; |
@ -0,0 +1,21 @@ |
||||
SELECT |
||||
{{ .Ident "guid" }}, |
||||
{{ .Ident "name" }}, |
||||
{{ .Ident "namespace" }}, |
||||
{{ .Ident "annotations" }}, |
||||
{{ .Ident "labels" }}, |
||||
{{ .Ident "created" }}, |
||||
{{ .Ident "created_by" }}, |
||||
{{ .Ident "updated" }}, |
||||
{{ .Ident "updated_by" }}, |
||||
{{ .Ident "description" }}, |
||||
{{ .Ident "type" }}, |
||||
{{ .Ident "payload" }} |
||||
FROM |
||||
{{ .Ident "secret_keeper" }} |
||||
WHERE {{ .Ident "namespace" }} = {{ .Arg .Namespace }} AND |
||||
{{ .Ident "name" }} = {{ .Arg .Name }} |
||||
{{ if .IsForUpdate }} |
||||
{{ .SelectFor "UPDATE" }} |
||||
{{ end }} |
||||
; |
@ -0,0 +1,18 @@ |
||||
UPDATE |
||||
{{ .Ident "secret_keeper" }} |
||||
SET |
||||
{{ .Ident "guid" }} = {{ .Arg .Row.GUID }}, |
||||
{{ .Ident "name" }} = {{ .Arg .Row.Name }}, |
||||
{{ .Ident "namespace" }} = {{ .Arg .Row.Namespace }}, |
||||
{{ .Ident "annotations" }} = {{ .Arg .Row.Annotations }}, |
||||
{{ .Ident "labels" }} = {{ .Arg .Row.Labels }}, |
||||
{{ .Ident "created" }} = {{ .Arg .Row.Created }}, |
||||
{{ .Ident "created_by" }} = {{ .Arg .Row.CreatedBy }}, |
||||
{{ .Ident "updated" }} = {{ .Arg .Row.Updated }}, |
||||
{{ .Ident "updated_by" }} = {{ .Arg .Row.UpdatedBy }}, |
||||
{{ .Ident "description" }} = {{ .Arg .Row.Description }}, |
||||
{{ .Ident "type" }} = {{ .Arg .Row.Type }}, |
||||
{{ .Ident "payload" }} = {{ .Arg .Row.Payload }} |
||||
WHERE {{ .Ident "namespace" }} = {{ .Arg .Row.Namespace }} AND |
||||
{{ .Ident "name" }} = {{ .Arg .Row.Name }} |
||||
; |
@ -0,0 +1,248 @@ |
||||
package metadata |
||||
|
||||
import ( |
||||
"encoding/json" |
||||
"fmt" |
||||
"time" |
||||
|
||||
"github.com/google/uuid" |
||||
"github.com/grafana/grafana/pkg/apimachinery/utils" |
||||
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube" |
||||
"github.com/grafana/grafana/pkg/storage/secret/migrator" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/types" |
||||
) |
||||
|
||||
type keeperDB struct { |
||||
// Kubernetes Metadata
|
||||
GUID string |
||||
Name string |
||||
Namespace string |
||||
Annotations string // map[string]string
|
||||
Labels string // map[string]string
|
||||
Created int64 |
||||
CreatedBy string |
||||
Updated int64 |
||||
UpdatedBy string |
||||
|
||||
// Spec
|
||||
Description string |
||||
Type string |
||||
Payload string |
||||
} |
||||
|
||||
func (*keeperDB) TableName() string { |
||||
return migrator.TableNameKeeper |
||||
} |
||||
|
||||
// toKubernetes maps a DB row into a Kubernetes resource (metadata + spec).
|
||||
func (kp *keeperDB) toKubernetes() (*secretv0alpha1.Keeper, error) { |
||||
annotations := make(map[string]string, 0) |
||||
if kp.Annotations != "" { |
||||
if err := json.Unmarshal([]byte(kp.Annotations), &annotations); err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal annotations: %w", err) |
||||
} |
||||
} |
||||
|
||||
labels := make(map[string]string, 0) |
||||
if kp.Labels != "" { |
||||
if err := json.Unmarshal([]byte(kp.Labels), &labels); err != nil { |
||||
return nil, fmt.Errorf("failed to unmarshal labels: %w", err) |
||||
} |
||||
} |
||||
|
||||
resource := &secretv0alpha1.Keeper{ |
||||
Spec: secretv0alpha1.KeeperSpec{ |
||||
Description: kp.Description, |
||||
}, |
||||
} |
||||
|
||||
// Obtain provider configs
|
||||
provider := toProvider(secretv0alpha1.KeeperType(kp.Type), kp.Payload) |
||||
switch v := provider.(type) { |
||||
case *secretv0alpha1.AWSKeeperConfig: |
||||
resource.Spec.AWS = v |
||||
case *secretv0alpha1.AzureKeeperConfig: |
||||
resource.Spec.Azure = v |
||||
case *secretv0alpha1.GCPKeeperConfig: |
||||
resource.Spec.GCP = v |
||||
case *secretv0alpha1.HashiCorpKeeperConfig: |
||||
resource.Spec.HashiCorp = v |
||||
} |
||||
|
||||
// Set all meta fields here for consistency.
|
||||
meta, err := utils.MetaAccessor(resource) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to get meta accessor: %w", err) |
||||
} |
||||
|
||||
updated := time.Unix(kp.Updated, 0).UTC() |
||||
|
||||
meta.SetUID(types.UID(kp.GUID)) |
||||
meta.SetName(kp.Name) |
||||
meta.SetNamespace(kp.Namespace) |
||||
meta.SetAnnotations(annotations) |
||||
meta.SetLabels(labels) |
||||
meta.SetCreatedBy(kp.CreatedBy) |
||||
meta.SetCreationTimestamp(metav1.NewTime(time.Unix(kp.Created, 0).UTC())) |
||||
meta.SetUpdatedBy(kp.UpdatedBy) |
||||
meta.SetUpdatedTimestamp(&updated) |
||||
meta.SetResourceVersionInt64(kp.Updated) |
||||
|
||||
return resource, nil |
||||
} |
||||
|
||||
// toKeeperCreateRow maps a Kubernetes resource into a DB row for new resources being created/inserted.
|
||||
func toKeeperCreateRow(kp *secretv0alpha1.Keeper, actorUID string) (*keeperDB, error) { |
||||
row, err := toKeeperRow(kp) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to map to row: %w", err) |
||||
} |
||||
|
||||
now := time.Now().UTC().Unix() |
||||
|
||||
row.GUID = uuid.New().String() |
||||
row.Created = now |
||||
row.CreatedBy = actorUID |
||||
row.Updated = now |
||||
row.UpdatedBy = actorUID |
||||
|
||||
return row, nil |
||||
} |
||||
|
||||
// toKeeperUpdateRow maps a Kubernetes resource into a DB row for existing resources being updated.
|
||||
func toKeeperUpdateRow(currentRow *keeperDB, newKeeper *secretv0alpha1.Keeper, actorUID string) (*keeperDB, error) { |
||||
row, err := toKeeperRow(newKeeper) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to map to row: %w", err) |
||||
} |
||||
|
||||
now := time.Now().UTC().Unix() |
||||
|
||||
row.GUID = currentRow.GUID |
||||
row.Created = currentRow.Created |
||||
row.CreatedBy = currentRow.CreatedBy |
||||
row.Updated = now |
||||
row.UpdatedBy = actorUID |
||||
|
||||
return row, nil |
||||
} |
||||
|
||||
// toKeeperRow maps a Kubernetes Keeper resource into a Keeper DB row.
|
||||
func toKeeperRow(kp *secretv0alpha1.Keeper) (*keeperDB, error) { |
||||
var annotations string |
||||
if len(kp.Annotations) > 0 { |
||||
cleanedAnnotations := xkube.CleanAnnotations(kp.Annotations) |
||||
if len(cleanedAnnotations) > 0 { |
||||
kp.Annotations = make(map[string]string) // Safety: reset to prohibit use of kp.Annotations further.
|
||||
|
||||
encodedAnnotations, err := json.Marshal(cleanedAnnotations) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to encode annotations: %w", err) |
||||
} |
||||
|
||||
annotations = string(encodedAnnotations) |
||||
} |
||||
} |
||||
|
||||
var labels string |
||||
if len(kp.Labels) > 0 { |
||||
encodedLabels, err := json.Marshal(kp.Labels) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to encode labels: %w", err) |
||||
} |
||||
|
||||
labels = string(encodedLabels) |
||||
} |
||||
|
||||
meta, err := utils.MetaAccessor(kp) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to get meta accessor: %w", err) |
||||
} |
||||
|
||||
if meta.GetFolder() != "" { |
||||
return nil, fmt.Errorf("folders are not supported") |
||||
} |
||||
|
||||
updatedTimestamp, err := meta.GetResourceVersionInt64() |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to get resource version: %w", err) |
||||
} |
||||
|
||||
keeperType, keeperPayload, err := toTypeAndPayload(kp) |
||||
if err != nil { |
||||
return nil, fmt.Errorf("failed to obtain type and payload: %w", err) |
||||
} |
||||
|
||||
return &keeperDB{ |
||||
// Kubernetes Metadata
|
||||
GUID: string(kp.UID), |
||||
Name: kp.Name, |
||||
Namespace: kp.Namespace, |
||||
Annotations: annotations, |
||||
Labels: labels, |
||||
Created: meta.GetCreationTimestamp().Unix(), |
||||
CreatedBy: meta.GetCreatedBy(), |
||||
Updated: updatedTimestamp, |
||||
UpdatedBy: meta.GetUpdatedBy(), |
||||
|
||||
// Spec
|
||||
Description: kp.Spec.Description, |
||||
Type: keeperType.String(), |
||||
Payload: keeperPayload, |
||||
}, nil |
||||
} |
||||
|
||||
// toTypeAndPayload obtain keeper type and payload from a Kubernetes Keeper resource.
|
||||
// TODO: Move as method of KeeperSpec
|
||||
func toTypeAndPayload(kp *secretv0alpha1.Keeper) (secretv0alpha1.KeeperType, string, error) { |
||||
if kp.Spec.AWS != nil { |
||||
payload, err := json.Marshal(kp.Spec.AWS.AWSCredentials) |
||||
return secretv0alpha1.AWSKeeperType, string(payload), err |
||||
} else if kp.Spec.Azure != nil { |
||||
payload, err := json.Marshal(kp.Spec.Azure) |
||||
return secretv0alpha1.AzureKeeperType, string(payload), err |
||||
} else if kp.Spec.GCP != nil { |
||||
payload, err := json.Marshal(kp.Spec.GCP) |
||||
return secretv0alpha1.GCPKeeperType, string(payload), err |
||||
} else if kp.Spec.HashiCorp != nil { |
||||
payload, err := json.Marshal(kp.Spec.HashiCorp) |
||||
return secretv0alpha1.HashiCorpKeeperType, string(payload), err |
||||
} |
||||
|
||||
return "", "", fmt.Errorf("no keeper type found") |
||||
} |
||||
|
||||
// toProvider maps a KeeperType and payload into a provider config struct.
|
||||
// TODO: Move as method of KeeperType
|
||||
func toProvider(keeperType secretv0alpha1.KeeperType, payload string) secretv0alpha1.KeeperConfig { |
||||
switch keeperType { |
||||
case secretv0alpha1.AWSKeeperType: |
||||
aws := &secretv0alpha1.AWSKeeperConfig{} |
||||
if err := json.Unmarshal([]byte(payload), aws); err != nil { |
||||
return nil |
||||
} |
||||
return aws |
||||
case secretv0alpha1.AzureKeeperType: |
||||
azure := &secretv0alpha1.AzureKeeperConfig{} |
||||
if err := json.Unmarshal([]byte(payload), azure); err != nil { |
||||
return nil |
||||
} |
||||
return azure |
||||
case secretv0alpha1.GCPKeeperType: |
||||
gcp := &secretv0alpha1.GCPKeeperConfig{} |
||||
if err := json.Unmarshal([]byte(payload), gcp); err != nil { |
||||
return nil |
||||
} |
||||
return gcp |
||||
case secretv0alpha1.HashiCorpKeeperType: |
||||
hashicorp := &secretv0alpha1.HashiCorpKeeperConfig{} |
||||
if err := json.Unmarshal([]byte(payload), hashicorp); err != nil { |
||||
return nil |
||||
} |
||||
return hashicorp |
||||
default: |
||||
return nil |
||||
} |
||||
} |
@ -0,0 +1,344 @@ |
||||
package metadata |
||||
|
||||
import ( |
||||
"context" |
||||
"testing" |
||||
|
||||
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts" |
||||
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/services/sqlstore" |
||||
"github.com/grafana/grafana/pkg/storage/secret/database" |
||||
"github.com/grafana/grafana/pkg/storage/secret/migrator" |
||||
"github.com/stretchr/testify/require" |
||||
) |
||||
|
||||
func Test_KeeperMetadataStorage_GetKeeperConfig(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
defaultKeeperName := "kp-test" |
||||
defaultKeeperNS := "default" |
||||
|
||||
testKeeper := &secretv0alpha1.Keeper{ |
||||
Spec: secretv0alpha1.KeeperSpec{ |
||||
Description: "description", |
||||
AWS: &secretv0alpha1.AWSKeeperConfig{}, |
||||
}, |
||||
} |
||||
|
||||
testKeeper.Name = defaultKeeperName |
||||
testKeeper.Namespace = defaultKeeperNS |
||||
|
||||
t.Run("get the system keeper config", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
// get system keeper config
|
||||
keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, nil, contracts.ReadOpts{}) |
||||
require.NoError(t, err) |
||||
require.Nil(t, keeperConfig) |
||||
}) |
||||
|
||||
t.Run("get test keeper config", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
//
|
||||
_, err := keeperMetadataStorage.Create(ctx, testKeeper, "testuser") |
||||
require.NoError(t, err) |
||||
|
||||
keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, defaultKeeperNS, &defaultKeeperName, contracts.ReadOpts{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, keeperConfig) |
||||
require.NotEmpty(t, keeperConfig.Type()) |
||||
}) |
||||
|
||||
t.Run("get test keeper config when listing", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
//
|
||||
_, err := keeperMetadataStorage.Create(ctx, testKeeper, "testuser") |
||||
require.NoError(t, err) |
||||
|
||||
keeperList, err := keeperMetadataStorage.List(ctx, xkube.Namespace(defaultKeeperNS)) |
||||
require.NoError(t, err) |
||||
require.NotEmpty(t, keeperList) |
||||
|
||||
require.Len(t, keeperList, 1) |
||||
keeper := keeperList[0] |
||||
require.Equal(t, "kp-test", keeper.Name) |
||||
require.Equal(t, "default", keeper.Namespace) |
||||
require.Equal(t, "description", keeper.Spec.Description) |
||||
}) |
||||
|
||||
t.Run("create a keeper and then delete it", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
keeperTest := "kp-test2" |
||||
keeperNamespaceTest := "ns" |
||||
|
||||
testKeeper := &secretv0alpha1.Keeper{ |
||||
Spec: secretv0alpha1.KeeperSpec{ |
||||
Description: "another description", |
||||
AWS: &secretv0alpha1.AWSKeeperConfig{}, |
||||
}, |
||||
} |
||||
testKeeper.Name = keeperTest |
||||
testKeeper.Namespace = keeperNamespaceTest |
||||
|
||||
// create the keeper
|
||||
_, err := keeperMetadataStorage.Create(ctx, testKeeper, "testuser") |
||||
require.NoError(t, err) |
||||
|
||||
// we are able to get it
|
||||
keeperConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, &keeperTest, contracts.ReadOpts{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, keeperConfig) |
||||
require.NotEmpty(t, keeperConfig.Type()) |
||||
|
||||
// now we delete it
|
||||
delErr := keeperMetadataStorage.Delete(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest) |
||||
require.NoError(t, delErr) |
||||
|
||||
// and we shouldn't be able to get it again
|
||||
_, getErr := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, &keeperTest, contracts.ReadOpts{}) |
||||
require.Errorf(t, getErr, "keeper not found") |
||||
}) |
||||
|
||||
t.Run("create, update and validate keeper", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
keeperTest := "kp-test3" |
||||
keeperNamespaceTest := "ns" |
||||
|
||||
// Create initial keeper
|
||||
initialKeeper := &secretv0alpha1.Keeper{ |
||||
Spec: secretv0alpha1.KeeperSpec{ |
||||
Description: "initial description", |
||||
AWS: &secretv0alpha1.AWSKeeperConfig{}, |
||||
}, |
||||
} |
||||
initialKeeper.Name = keeperTest |
||||
initialKeeper.Namespace = keeperNamespaceTest |
||||
|
||||
// Create the keeper
|
||||
_, err := keeperMetadataStorage.Create(ctx, initialKeeper, "testuser") |
||||
require.NoError(t, err) |
||||
|
||||
// Validate that the description was set
|
||||
keeper, err := keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) |
||||
require.NoError(t, err) |
||||
require.Equal(t, "initial description", keeper.Spec.Description) |
||||
|
||||
// Update the keeper with new values
|
||||
updatedKeeper := &secretv0alpha1.Keeper{ |
||||
Spec: secretv0alpha1.KeeperSpec{ |
||||
Description: "updated description", |
||||
AWS: &secretv0alpha1.AWSKeeperConfig{}, |
||||
}, |
||||
} |
||||
updatedKeeper.Name = keeperTest |
||||
updatedKeeper.Namespace = keeperNamespaceTest |
||||
|
||||
// Perform the update
|
||||
_, err = keeperMetadataStorage.Update(ctx, updatedKeeper, "testuser") |
||||
require.NoError(t, err) |
||||
|
||||
// Validate updated values
|
||||
updatedConfig, err := keeperMetadataStorage.GetKeeperConfig(ctx, keeperNamespaceTest, &keeperTest, contracts.ReadOpts{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, updatedConfig) |
||||
require.NotEmpty(t, updatedConfig.Type()) |
||||
|
||||
// Validate that the description was updated
|
||||
updatedKeeper, err = keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) |
||||
require.NoError(t, err) |
||||
require.Equal(t, "updated description", updatedKeeper.Spec.Description) |
||||
}) |
||||
|
||||
t.Run("update keeper with different AWS configuration", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
keeperTest := "kp-test4" |
||||
keeperNamespaceTest := "ns" |
||||
|
||||
// Create initial keeper with first AWS config
|
||||
initialKeeper := &secretv0alpha1.Keeper{ |
||||
Spec: secretv0alpha1.KeeperSpec{ |
||||
Description: "initial description", |
||||
AWS: &secretv0alpha1.AWSKeeperConfig{ |
||||
AWSCredentials: secretv0alpha1.AWSCredentials{ |
||||
AccessKeyID: secretv0alpha1.CredentialValue{ |
||||
ValueFromEnv: "AWS_ACCESS_KEY_ID_1", |
||||
}, |
||||
SecretAccessKey: secretv0alpha1.CredentialValue{ |
||||
ValueFromEnv: "AWS_SECRET_ACCESS_KEY_1", |
||||
}, |
||||
KMSKeyID: "kms-key-id-1", |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
initialKeeper.Name = keeperTest |
||||
initialKeeper.Namespace = keeperNamespaceTest |
||||
|
||||
// Create the keeper
|
||||
_, err := keeperMetadataStorage.Create(ctx, initialKeeper, "testuser") |
||||
require.NoError(t, err) |
||||
|
||||
// Verify initial AWS config
|
||||
keeper, err := keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) |
||||
require.NoError(t, err) |
||||
require.Equal(t, "AWS_ACCESS_KEY_ID_1", keeper.Spec.AWS.AccessKeyID.ValueFromEnv) |
||||
require.Equal(t, "AWS_SECRET_ACCESS_KEY_1", keeper.Spec.AWS.SecretAccessKey.ValueFromEnv) |
||||
require.Equal(t, "kms-key-id-1", keeper.Spec.AWS.KMSKeyID) |
||||
|
||||
// Update with new AWS config
|
||||
updatedKeeper := &secretv0alpha1.Keeper{ |
||||
Spec: secretv0alpha1.KeeperSpec{ |
||||
Description: "updated description", |
||||
AWS: &secretv0alpha1.AWSKeeperConfig{ |
||||
AWSCredentials: secretv0alpha1.AWSCredentials{ |
||||
AccessKeyID: secretv0alpha1.CredentialValue{ |
||||
ValueFromEnv: "AWS_ACCESS_KEY_ID_2", |
||||
}, |
||||
SecretAccessKey: secretv0alpha1.CredentialValue{ |
||||
ValueFromEnv: "AWS_SECRET_ACCESS_KEY_2", |
||||
}, |
||||
KMSKeyID: "kms-key-id-2", |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
updatedKeeper.Name = keeperTest |
||||
updatedKeeper.Namespace = keeperNamespaceTest |
||||
|
||||
// Perform the update
|
||||
_, err = keeperMetadataStorage.Update(ctx, updatedKeeper, "testuser") |
||||
require.NoError(t, err) |
||||
|
||||
// Verify updated AWS config
|
||||
updatedKeeper, err = keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) |
||||
require.NoError(t, err) |
||||
require.Equal(t, "AWS_ACCESS_KEY_ID_2", updatedKeeper.Spec.AWS.AccessKeyID.ValueFromEnv) |
||||
require.Equal(t, "AWS_SECRET_ACCESS_KEY_2", updatedKeeper.Spec.AWS.SecretAccessKey.ValueFromEnv) |
||||
require.Equal(t, "kms-key-id-2", updatedKeeper.Spec.AWS.KMSKeyID) |
||||
}) |
||||
|
||||
t.Run("list keepers in empty namespace", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
keeperList, err := keeperMetadataStorage.List(ctx, "") |
||||
require.NoError(t, err) |
||||
require.Empty(t, keeperList) |
||||
}) |
||||
|
||||
t.Run("read non-existent keeper", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
_, err := keeperMetadataStorage.Read(ctx, "ns", "non-existent", contracts.ReadOpts{}) |
||||
require.Error(t, err) |
||||
require.Equal(t, contracts.ErrKeeperNotFound, err) |
||||
}) |
||||
|
||||
t.Run("update keeper with different namespace", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
keeperTest := "kp-test5" |
||||
keeperNamespaceTest := "ns1" |
||||
|
||||
// Create initial keeper
|
||||
initialKeeper := &secretv0alpha1.Keeper{ |
||||
Spec: secretv0alpha1.KeeperSpec{ |
||||
Description: "initial description", |
||||
AWS: &secretv0alpha1.AWSKeeperConfig{ |
||||
AWSCredentials: secretv0alpha1.AWSCredentials{ |
||||
AccessKeyID: secretv0alpha1.CredentialValue{ |
||||
ValueFromEnv: "AWS_ACCESS_KEY_ID", |
||||
}, |
||||
SecretAccessKey: secretv0alpha1.CredentialValue{ |
||||
ValueFromEnv: "AWS_SECRET_ACCESS_KEY", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
initialKeeper.Name = keeperTest |
||||
initialKeeper.Namespace = keeperNamespaceTest |
||||
|
||||
// Create the keeper
|
||||
_, err := keeperMetadataStorage.Create(ctx, initialKeeper, "testuser") |
||||
require.NoError(t, err) |
||||
|
||||
// Try to update with different namespace
|
||||
updatedKeeper := initialKeeper.DeepCopy() |
||||
updatedKeeper.Namespace = "ns2" |
||||
updatedKeeper.Spec.Description = "updated description" |
||||
|
||||
_, err = keeperMetadataStorage.Update(ctx, updatedKeeper, "testuser") |
||||
// should this return contracts.ErrKeeperNotFound directly?
|
||||
// require.Equal(t, contracts.ErrKeeperNotFound, err)
|
||||
require.Error(t, err, "db failure: keeper not found") |
||||
|
||||
// Verify original keeper is unchanged
|
||||
keeper, err := keeperMetadataStorage.Read(ctx, xkube.Namespace(keeperNamespaceTest), keeperTest, contracts.ReadOpts{}) |
||||
require.NoError(t, err) |
||||
require.Equal(t, "initial description", keeper.Spec.Description) |
||||
}) |
||||
|
||||
t.Run("update non-existent keeper", func(t *testing.T) { |
||||
t.Parallel() |
||||
|
||||
ctx := context.Background() |
||||
keeperMetadataStorage := initStorage(t) |
||||
|
||||
nonExistentKeeper := &secretv0alpha1.Keeper{ |
||||
Spec: secretv0alpha1.KeeperSpec{ |
||||
Description: "some description", |
||||
AWS: &secretv0alpha1.AWSKeeperConfig{}, |
||||
}, |
||||
} |
||||
nonExistentKeeper.Name = "non-existent" |
||||
nonExistentKeeper.Namespace = "ns" |
||||
|
||||
_, err := keeperMetadataStorage.Update(ctx, nonExistentKeeper, "testuser") |
||||
require.Error(t, err, "db failure: keeper not found") |
||||
}) |
||||
} |
||||
|
||||
func initStorage(t *testing.T) contracts.KeeperMetadataStorage { |
||||
testDB := sqlstore.NewTestStore(t, sqlstore.WithMigrator(migrator.New())) |
||||
db := database.ProvideDatabase(testDB) |
||||
features := featuremgmt.WithFeatures(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, featuremgmt.FlagSecretsManagementAppPlatform) |
||||
|
||||
// Initialize the keeper storage
|
||||
keeperMetadataStorage, err := ProvideKeeperMetadataStorage(db, features) |
||||
require.NoError(t, err) |
||||
return keeperMetadataStorage |
||||
} |
@ -0,0 +1,106 @@ |
||||
package metadata |
||||
|
||||
import ( |
||||
"embed" |
||||
"fmt" |
||||
"text/template" |
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" |
||||
) |
||||
|
||||
var ( |
||||
//go:embed data/*.sql
|
||||
sqlTemplatesFS embed.FS |
||||
|
||||
sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `data/*.sql`)) |
||||
|
||||
// The SQL Commands
|
||||
sqlKeeperCreate = mustTemplate("keeper_create.sql") |
||||
sqlKeeperRead = mustTemplate("keeper_read.sql") |
||||
sqlKeeperUpdate = mustTemplate("keeper_update.sql") |
||||
sqlKeeperList = mustTemplate("keeper_list.sql") |
||||
sqlKeeperDelete = mustTemplate("keeper_delete.sql") |
||||
|
||||
sqlKeeperListByName = mustTemplate("keeper_listByName.sql") |
||||
) |
||||
|
||||
func mustTemplate(filename string) *template.Template { |
||||
if t := sqlTemplates.Lookup(filename); t != nil { |
||||
return t |
||||
} |
||||
panic(fmt.Sprintf("template file not found: %s", filename)) |
||||
} |
||||
|
||||
/************************/ |
||||
/**-- Keeper Queries --**/ |
||||
/************************/ |
||||
|
||||
// Create
|
||||
type createKeeper struct { |
||||
sqltemplate.SQLTemplate |
||||
Row *keeperDB |
||||
} |
||||
|
||||
// Validate is only used if we use `dbutil` from `unifiedstorage`
|
||||
func (r createKeeper) Validate() error { |
||||
return nil // TODO
|
||||
} |
||||
|
||||
// Read
|
||||
type readKeeper struct { |
||||
sqltemplate.SQLTemplate |
||||
Namespace string |
||||
Name string |
||||
IsForUpdate bool |
||||
} |
||||
|
||||
// Validate is only used if we use `dbutil` from `unifiedstorage`
|
||||
func (r readKeeper) Validate() error { |
||||
return nil // TODO
|
||||
} |
||||
|
||||
// Update
|
||||
type updateKeeper struct { |
||||
sqltemplate.SQLTemplate |
||||
Row *keeperDB |
||||
} |
||||
|
||||
// Validate is only used if we use `dbutil` from `unifiedstorage`
|
||||
func (r updateKeeper) Validate() error { |
||||
return nil // TODO
|
||||
} |
||||
|
||||
// List
|
||||
type listKeeper struct { |
||||
sqltemplate.SQLTemplate |
||||
Namespace string |
||||
} |
||||
|
||||
// Validate is only used if we use `dbutil` from `unifiedstorage`
|
||||
func (r listKeeper) Validate() error { |
||||
return nil // TODO
|
||||
} |
||||
|
||||
// Delete
|
||||
type deleteKeeper struct { |
||||
sqltemplate.SQLTemplate |
||||
Namespace string |
||||
Name string |
||||
} |
||||
|
||||
// Validate is only used if we use `dbutil` from `unifiedstorage`
|
||||
func (r deleteKeeper) Validate() error { |
||||
return nil // TODO
|
||||
} |
||||
|
||||
// This is used at keeper store to validate create & update operations
|
||||
type listByNameKeeper struct { |
||||
sqltemplate.SQLTemplate |
||||
Namespace string |
||||
KeeperNames []string |
||||
} |
||||
|
||||
// Validate is only used if we use `dbutil` from `unifiedstorage`
|
||||
func (r listByNameKeeper) Validate() error { |
||||
return nil // TODO
|
||||
} |
@ -0,0 +1,108 @@ |
||||
package metadata |
||||
|
||||
import ( |
||||
"testing" |
||||
"text/template" |
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks" |
||||
) |
||||
|
||||
func TestKeeperQueries(t *testing.T) { |
||||
mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{ |
||||
RootDir: "testdata", |
||||
Templates: map[*template.Template][]mocks.TemplateTestCase{ |
||||
sqlKeeperCreate: { |
||||
{ |
||||
Name: "create", |
||||
Data: &createKeeper{ |
||||
SQLTemplate: mocks.NewTestingSQLTemplate(), |
||||
Row: &keeperDB{ |
||||
GUID: "abc", |
||||
Name: "name", |
||||
Namespace: "ns", |
||||
Annotations: `{"x":"XXXX"}`, |
||||
Labels: `{"a":"AAA", "b", "BBBB"}`, |
||||
Created: 1234, |
||||
CreatedBy: "user:ryan", |
||||
Updated: 5678, |
||||
UpdatedBy: "user:cameron", |
||||
Description: "description", |
||||
Type: "sql", |
||||
Payload: "", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
sqlKeeperDelete: { |
||||
{ |
||||
Name: "delete", |
||||
Data: &deleteKeeper{ |
||||
SQLTemplate: mocks.NewTestingSQLTemplate(), |
||||
Name: "name", |
||||
Namespace: "ns", |
||||
}, |
||||
}, |
||||
}, |
||||
sqlKeeperList: { |
||||
{ |
||||
Name: "list", |
||||
Data: &listKeeper{ |
||||
SQLTemplate: mocks.NewTestingSQLTemplate(), |
||||
Namespace: "ns", |
||||
}, |
||||
}, |
||||
}, |
||||
sqlKeeperRead: { |
||||
{ |
||||
Name: "read", |
||||
Data: &readKeeper{ |
||||
SQLTemplate: mocks.NewTestingSQLTemplate(), |
||||
Name: "name", |
||||
Namespace: "ns", |
||||
}, |
||||
}, |
||||
{ |
||||
Name: "read-for-update", |
||||
Data: &readKeeper{ |
||||
SQLTemplate: mocks.NewTestingSQLTemplate(), |
||||
Name: "name", |
||||
Namespace: "ns", |
||||
IsForUpdate: true, |
||||
}, |
||||
}, |
||||
}, |
||||
sqlKeeperUpdate: { |
||||
{ |
||||
Name: "update", |
||||
Data: &updateKeeper{ |
||||
SQLTemplate: mocks.NewTestingSQLTemplate(), |
||||
Row: &keeperDB{ |
||||
GUID: "abc", |
||||
Name: "name", |
||||
Namespace: "ns", |
||||
Annotations: `{"x":"XXXX"}`, |
||||
Labels: `{"a":"AAA", "b", "BBBB"}`, |
||||
Created: 1234, |
||||
CreatedBy: "user:ryan", |
||||
Updated: 5678, |
||||
UpdatedBy: "user:cameron", |
||||
Description: "description", |
||||
Type: "sql", |
||||
Payload: "", |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
sqlKeeperListByName: { |
||||
{ |
||||
Name: "list", |
||||
Data: listByNameKeeper{ |
||||
SQLTemplate: mocks.NewTestingSQLTemplate(), |
||||
Namespace: "ns", |
||||
KeeperNames: []string{"a", "b"}, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}) |
||||
} |
@ -0,0 +1,27 @@ |
||||
INSERT INTO `secret_keeper` ( |
||||
`guid`, |
||||
`name`, |
||||
`namespace`, |
||||
`annotations`, |
||||
`labels`, |
||||
`created`, |
||||
`created_by`, |
||||
`updated`, |
||||
`updated_by`, |
||||
`description`, |
||||
`type`, |
||||
`payload` |
||||
) VALUES ( |
||||
'abc', |
||||
'name', |
||||
'ns', |
||||
'{"x":"XXXX"}', |
||||
'{"a":"AAA", "b", "BBBB"}', |
||||
1234, |
||||
'user:ryan', |
||||
5678, |
||||
'user:cameron', |
||||
'description', |
||||
'sql', |
||||
'' |
||||
); |
@ -0,0 +1,4 @@ |
||||
DELETE FROM `secret_keeper` |
||||
WHERE `namespace` = 'ns' AND |
||||
`name` = 'name' |
||||
; |
@ -0,0 +1,18 @@ |
||||
SELECT |
||||
`guid`, |
||||
`name`, |
||||
`namespace`, |
||||
`annotations`, |
||||
`labels`, |
||||
`created`, |
||||
`created_by`, |
||||
`updated`, |
||||
`updated_by`, |
||||
`description`, |
||||
`type`, |
||||
`payload` |
||||
FROM |
||||
`secret_keeper` |
||||
WHERE `namespace` = 'ns' |
||||
ORDER BY `updated` DESC |
||||
; |
@ -0,0 +1,8 @@ |
||||
SELECT |
||||
`name` |
||||
FROM |
||||
`secret_keeper` |
||||
WHERE `namespace` = 'ns' AND |
||||
`name` IN ('a', 'b') |
||||
FOR UPDATE |
||||
; |
@ -0,0 +1,19 @@ |
||||
SELECT |
||||
`guid`, |
||||
`name`, |
||||
`namespace`, |
||||
`annotations`, |
||||
`labels`, |
||||
`created`, |
||||
`created_by`, |
||||
`updated`, |
||||
`updated_by`, |
||||
`description`, |
||||
`type`, |
||||
`payload` |
||||
FROM |
||||
`secret_keeper` |
||||
WHERE `namespace` = 'ns' AND |
||||
`name` = 'name' |
||||
FOR UPDATE |
||||
; |
@ -0,0 +1,18 @@ |
||||
SELECT |
||||
`guid`, |
||||
`name`, |
||||
`namespace`, |
||||
`annotations`, |
||||
`labels`, |
||||
`created`, |
||||
`created_by`, |
||||
`updated`, |
||||
`updated_by`, |
||||
`description`, |
||||
`type`, |
||||
`payload` |
||||
FROM |
||||
`secret_keeper` |
||||
WHERE `namespace` = 'ns' AND |
||||
`name` = 'name' |
||||
; |
@ -0,0 +1,18 @@ |
||||
UPDATE |
||||
`secret_keeper` |
||||
SET |
||||
`guid` = 'abc', |
||||
`name` = 'name', |
||||
`namespace` = 'ns', |
||||
`annotations` = '{"x":"XXXX"}', |
||||
`labels` = '{"a":"AAA", "b", "BBBB"}', |
||||
`created` = 1234, |
||||
`created_by` = 'user:ryan', |
||||
`updated` = 5678, |
||||
`updated_by` = 'user:cameron', |
||||
`description` = 'description', |
||||
`type` = 'sql', |
||||
`payload` = '' |
||||
WHERE `namespace` = 'ns' AND |
||||
`name` = 'name' |
||||
; |
@ -0,0 +1,27 @@ |
||||
INSERT INTO "secret_keeper" ( |
||||
"guid", |
||||
"name", |
||||
"namespace", |
||||
"annotations", |
||||
"labels", |
||||
"created", |
||||
"created_by", |
||||
"updated", |
||||
"updated_by", |
||||
"description", |
||||
"type", |
||||
"payload" |
||||
) VALUES ( |
||||
'abc', |
||||
'name', |
||||
'ns', |
||||
'{"x":"XXXX"}', |
||||
'{"a":"AAA", "b", "BBBB"}', |
||||
1234, |
||||
'user:ryan', |
||||
5678, |
||||
'user:cameron', |
||||
'description', |
||||
'sql', |
||||
'' |
||||
); |
@ -0,0 +1,4 @@ |
||||
DELETE FROM "secret_keeper" |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" = 'name' |
||||
; |
@ -0,0 +1,18 @@ |
||||
SELECT |
||||
"guid", |
||||
"name", |
||||
"namespace", |
||||
"annotations", |
||||
"labels", |
||||
"created", |
||||
"created_by", |
||||
"updated", |
||||
"updated_by", |
||||
"description", |
||||
"type", |
||||
"payload" |
||||
FROM |
||||
"secret_keeper" |
||||
WHERE "namespace" = 'ns' |
||||
ORDER BY "updated" DESC |
||||
; |
@ -0,0 +1,8 @@ |
||||
SELECT |
||||
"name" |
||||
FROM |
||||
"secret_keeper" |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" IN ('a', 'b') |
||||
FOR UPDATE |
||||
; |
@ -0,0 +1,19 @@ |
||||
SELECT |
||||
"guid", |
||||
"name", |
||||
"namespace", |
||||
"annotations", |
||||
"labels", |
||||
"created", |
||||
"created_by", |
||||
"updated", |
||||
"updated_by", |
||||
"description", |
||||
"type", |
||||
"payload" |
||||
FROM |
||||
"secret_keeper" |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" = 'name' |
||||
FOR UPDATE |
||||
; |
@ -0,0 +1,18 @@ |
||||
SELECT |
||||
"guid", |
||||
"name", |
||||
"namespace", |
||||
"annotations", |
||||
"labels", |
||||
"created", |
||||
"created_by", |
||||
"updated", |
||||
"updated_by", |
||||
"description", |
||||
"type", |
||||
"payload" |
||||
FROM |
||||
"secret_keeper" |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" = 'name' |
||||
; |
@ -0,0 +1,18 @@ |
||||
UPDATE |
||||
"secret_keeper" |
||||
SET |
||||
"guid" = 'abc', |
||||
"name" = 'name', |
||||
"namespace" = 'ns', |
||||
"annotations" = '{"x":"XXXX"}', |
||||
"labels" = '{"a":"AAA", "b", "BBBB"}', |
||||
"created" = 1234, |
||||
"created_by" = 'user:ryan', |
||||
"updated" = 5678, |
||||
"updated_by" = 'user:cameron', |
||||
"description" = 'description', |
||||
"type" = 'sql', |
||||
"payload" = '' |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" = 'name' |
||||
; |
@ -0,0 +1,27 @@ |
||||
INSERT INTO "secret_keeper" ( |
||||
"guid", |
||||
"name", |
||||
"namespace", |
||||
"annotations", |
||||
"labels", |
||||
"created", |
||||
"created_by", |
||||
"updated", |
||||
"updated_by", |
||||
"description", |
||||
"type", |
||||
"payload" |
||||
) VALUES ( |
||||
'abc', |
||||
'name', |
||||
'ns', |
||||
'{"x":"XXXX"}', |
||||
'{"a":"AAA", "b", "BBBB"}', |
||||
1234, |
||||
'user:ryan', |
||||
5678, |
||||
'user:cameron', |
||||
'description', |
||||
'sql', |
||||
'' |
||||
); |
@ -0,0 +1,4 @@ |
||||
DELETE FROM "secret_keeper" |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" = 'name' |
||||
; |
@ -0,0 +1,18 @@ |
||||
SELECT |
||||
"guid", |
||||
"name", |
||||
"namespace", |
||||
"annotations", |
||||
"labels", |
||||
"created", |
||||
"created_by", |
||||
"updated", |
||||
"updated_by", |
||||
"description", |
||||
"type", |
||||
"payload" |
||||
FROM |
||||
"secret_keeper" |
||||
WHERE "namespace" = 'ns' |
||||
ORDER BY "updated" DESC |
||||
; |
@ -0,0 +1,7 @@ |
||||
SELECT |
||||
"name" |
||||
FROM |
||||
"secret_keeper" |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" IN ('a', 'b') |
||||
; |
@ -0,0 +1,18 @@ |
||||
SELECT |
||||
"guid", |
||||
"name", |
||||
"namespace", |
||||
"annotations", |
||||
"labels", |
||||
"created", |
||||
"created_by", |
||||
"updated", |
||||
"updated_by", |
||||
"description", |
||||
"type", |
||||
"payload" |
||||
FROM |
||||
"secret_keeper" |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" = 'name' |
||||
; |
@ -0,0 +1,18 @@ |
||||
SELECT |
||||
"guid", |
||||
"name", |
||||
"namespace", |
||||
"annotations", |
||||
"labels", |
||||
"created", |
||||
"created_by", |
||||
"updated", |
||||
"updated_by", |
||||
"description", |
||||
"type", |
||||
"payload" |
||||
FROM |
||||
"secret_keeper" |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" = 'name' |
||||
; |
@ -0,0 +1,18 @@ |
||||
UPDATE |
||||
"secret_keeper" |
||||
SET |
||||
"guid" = 'abc', |
||||
"name" = 'name', |
||||
"namespace" = 'ns', |
||||
"annotations" = '{"x":"XXXX"}', |
||||
"labels" = '{"a":"AAA", "b", "BBBB"}', |
||||
"created" = 1234, |
||||
"created_by" = 'user:ryan', |
||||
"updated" = 5678, |
||||
"updated_by" = 'user:cameron', |
||||
"description" = 'description', |
||||
"type" = 'sql', |
||||
"payload" = '' |
||||
WHERE "namespace" = 'ns' AND |
||||
"name" = 'name' |
||||
; |
@ -0,0 +1,569 @@ |
||||
package secret |
||||
|
||||
import ( |
||||
"context" |
||||
"errors" |
||||
"math/rand/v2" |
||||
"net/http" |
||||
"strconv" |
||||
"strings" |
||||
"testing" |
||||
|
||||
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/tests/apis" |
||||
"github.com/grafana/grafana/pkg/tests/testinfra" |
||||
"github.com/stretchr/testify/require" |
||||
apierrors "k8s.io/apimachinery/pkg/api/errors" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/runtime" |
||||
"k8s.io/apimachinery/pkg/runtime/schema" |
||||
) |
||||
|
||||
var gvrKeepers = schema.GroupVersionResource{ |
||||
Group: secretv0alpha1.GROUP, |
||||
Version: secretv0alpha1.VERSION, |
||||
Resource: secretv0alpha1.KeeperResourceInfo.GetName(), |
||||
} |
||||
|
||||
func TestIntegrationKeeper(t *testing.T) { |
||||
if testing.Short() { |
||||
t.Skip("skipping integration test") |
||||
} |
||||
|
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
t.Cleanup(cancel) |
||||
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ |
||||
AppModeProduction: false, // required for experimental APIs
|
||||
EnableFeatureToggles: []string{ |
||||
// Required to start the example service
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, |
||||
featuremgmt.FlagSecretsManagementAppPlatform, |
||||
}, |
||||
}) |
||||
|
||||
permissions := map[string]ResourcePermission{ResourceKeepers: {Actions: ActionsAllKeepers}} |
||||
|
||||
genericUserEditor := mustCreateUsers(t, helper, permissions).Editor |
||||
|
||||
client := helper.GetResourceClient(apis.ResourceClientArgs{ |
||||
User: genericUserEditor, |
||||
GVR: gvrKeepers, |
||||
}) |
||||
|
||||
t.Run("reading a keeper that does not exist returns a 404", func(t *testing.T) { |
||||
raw, err := client.Resource.Get(ctx, "some-keeper-that-does-not-exist", metav1.GetOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, raw) |
||||
|
||||
var statusErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusErr)) |
||||
require.Equal(t, "keeper.secret.grafana.app \"some-keeper-that-does-not-exist\" not found", err.Error()) |
||||
require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) |
||||
}) |
||||
|
||||
t.Run("deleting a keeper that does not exist returns an error", func(t *testing.T) { |
||||
err := client.Resource.Delete(ctx, "some-keeper-that-does-not-exist", metav1.DeleteOptions{}) |
||||
require.Error(t, err) |
||||
|
||||
var statusErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusErr)) |
||||
require.Equal(t, "keeper.secret.grafana.app \"some-keeper-that-does-not-exist\" not found", err.Error()) |
||||
require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) |
||||
}) |
||||
|
||||
t.Run("creating a keeper returns it", func(t *testing.T) { |
||||
raw := mustGenerateKeeper(t, helper, genericUserEditor, nil, "testdata/keeper-aws-generate.yaml") |
||||
|
||||
keeper := new(secretv0alpha1.Keeper) |
||||
err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, keeper) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, keeper) |
||||
|
||||
require.NotEmpty(t, keeper.Spec.Description) |
||||
require.NotEmpty(t, keeper.Spec.AWS) |
||||
require.Empty(t, keeper.Spec.Azure) |
||||
|
||||
t.Run("and creating another keeper with the same name in the same namespace returns an error", func(t *testing.T) { |
||||
testKeeper := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") |
||||
testKeeper.SetName(raw.GetName()) |
||||
|
||||
raw, err := client.Resource.Create(ctx, testKeeper, metav1.CreateOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, raw) |
||||
}) |
||||
|
||||
t.Run("and reading th keeper returns it same as if when it was created", func(t *testing.T) { |
||||
raw, err := client.Resource.Get(ctx, keeper.Name, metav1.GetOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, raw) |
||||
|
||||
anotherKeeper := new(secretv0alpha1.Keeper) |
||||
err = runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, anotherKeeper) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, anotherKeeper) |
||||
|
||||
require.EqualValues(t, keeper, anotherKeeper) |
||||
}) |
||||
|
||||
t.Run("and listing keepers returns the created keeper", func(t *testing.T) { |
||||
rawList, err := client.Resource.List(ctx, metav1.ListOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawList) |
||||
require.GreaterOrEqual(t, len(rawList.Items), 1) |
||||
require.Equal(t, keeper.Name, rawList.Items[0].GetName()) |
||||
}) |
||||
|
||||
t.Run("and updating the keeper replaces the spec fields and returns them", func(t *testing.T) { |
||||
newRaw := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") |
||||
newRaw.SetName(raw.GetName()) |
||||
newRaw.Object["spec"].(map[string]any)["description"] = "New description" |
||||
newRaw.Object["metadata"].(map[string]any)["annotations"] = map[string]any{"newAnnotation": "newValue"} |
||||
|
||||
updatedRaw, err := client.Resource.Update(ctx, newRaw, metav1.UpdateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, updatedRaw) |
||||
|
||||
updatedKeeper := new(secretv0alpha1.Keeper) |
||||
err = runtime.DefaultUnstructuredConverter.FromUnstructured(updatedRaw.Object, updatedKeeper) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, updatedKeeper) |
||||
|
||||
require.NotEqualValues(t, updatedKeeper.Spec, keeper.Spec) |
||||
}) |
||||
|
||||
t.Run("and updating the keeper to reference securevalues that does not exist returns an error", func(t *testing.T) { |
||||
t.Skip("skipping because storing credentials as securevalues is not implemented yet") |
||||
newRaw := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") |
||||
newRaw.SetName(raw.GetName()) |
||||
newRaw.Object["spec"].(map[string]any)["aws"] = map[string]any{ |
||||
"accessKeyId": map[string]any{ |
||||
"secureValueName": "securevalue-does-not-exist-1", |
||||
}, |
||||
"secretAccessKey": map[string]any{ |
||||
"secureValueName": "securevalue-does-not-exist-2", |
||||
}, |
||||
} |
||||
|
||||
updatedRaw, err := client.Resource.Update(ctx, newRaw, metav1.UpdateOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, updatedRaw) |
||||
require.Contains(t, err.Error(), "securevalue-does-not-exist-1") |
||||
require.Contains(t, err.Error(), "securevalue-does-not-exist-2") |
||||
}) |
||||
}) |
||||
|
||||
t.Run("creating an invalid keeper fails validation and returns an error", func(t *testing.T) { |
||||
testData := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") |
||||
testData.Object["spec"].(map[string]any)["description"] = "" |
||||
|
||||
raw, err := client.Resource.Create(ctx, testData, metav1.CreateOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, raw) |
||||
|
||||
var statusErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusErr)) |
||||
}) |
||||
|
||||
t.Run("creating a keeper with a provider then changing the provider does not return an error", func(t *testing.T) { |
||||
rawAWS := mustGenerateKeeper(t, helper, genericUserEditor, nil, "testdata/keeper-aws-generate.yaml") |
||||
|
||||
testDataKeeperGCP := rawAWS.DeepCopy() |
||||
testDataKeeperGCP.Object["spec"].(map[string]any)["aws"] = nil |
||||
testDataKeeperGCP.Object["spec"].(map[string]any)["gcp"] = map[string]any{ |
||||
"projectId": "project-id", |
||||
"credentialsFile": "/path/to/file.json", |
||||
} |
||||
|
||||
rawGCP, err := client.Resource.Update(ctx, testDataKeeperGCP, metav1.UpdateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawGCP) |
||||
|
||||
require.NotEqualValues(t, rawAWS.Object["spec"], rawGCP.Object["spec"]) |
||||
}) |
||||
|
||||
t.Run("creating a keeper that references securevalues that does not exist returns an error", func(t *testing.T) { |
||||
t.Skip("skipping because storing credentials as securevalues is not implemented yet") |
||||
testDataKeeper := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") |
||||
testDataKeeper.Object["spec"].(map[string]any)["aws"] = map[string]any{ |
||||
"accessKeyId": map[string]any{ |
||||
"secureValueName": "securevalue-does-not-exist-1", |
||||
}, |
||||
"secretAccessKey": map[string]any{ |
||||
"secureValueName": "securevalue-does-not-exist-2", |
||||
}, |
||||
} |
||||
|
||||
raw, err := client.Resource.Create(ctx, testDataKeeper, metav1.CreateOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, raw) |
||||
require.Contains(t, err.Error(), "securevalue-does-not-exist-1") |
||||
require.Contains(t, err.Error(), "securevalue-does-not-exist-2") |
||||
}) |
||||
|
||||
t.Run("deleting a keeper that exists does not return an error", func(t *testing.T) { |
||||
generatePrefix := "generated-" |
||||
|
||||
testData := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") |
||||
testData.SetGenerateName(generatePrefix) |
||||
|
||||
raw, err := client.Resource.Create(ctx, testData, metav1.CreateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, raw) |
||||
|
||||
name := raw.GetName() |
||||
require.True(t, strings.HasPrefix(name, generatePrefix)) |
||||
|
||||
err = client.Resource.Delete(ctx, name, metav1.DeleteOptions{}) |
||||
require.NoError(t, err) |
||||
|
||||
t.Run("and then trying to read it returns a 404 error", func(t *testing.T) { |
||||
raw, err := client.Resource.Get(ctx, name, metav1.GetOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, raw) |
||||
|
||||
var statusErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusErr)) |
||||
require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) |
||||
}) |
||||
|
||||
t.Run("and listing keepers returns an empty list", func(t *testing.T) { |
||||
rawList, err := client.Resource.List(ctx, metav1.ListOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawList) |
||||
require.Empty(t, rawList.Items) |
||||
}) |
||||
}) |
||||
|
||||
t.Run("creating keepers in multiple namespaces", func(t *testing.T) { |
||||
permissions := map[string]ResourcePermission{ |
||||
ResourceKeepers: {Actions: ActionsAllKeepers}, |
||||
} |
||||
|
||||
editorOrgA := mustCreateUsers(t, helper, permissions).Editor |
||||
editorOrgB := mustCreateUsers(t, helper, permissions).Editor |
||||
|
||||
keeperOrgA := mustGenerateKeeper(t, helper, editorOrgA, nil, "testdata/keeper-aws-generate.yaml") |
||||
keeperOrgB := mustGenerateKeeper(t, helper, editorOrgB, nil, "testdata/keeper-aws-generate.yaml") |
||||
|
||||
clientOrgA := helper.GetResourceClient(apis.ResourceClientArgs{User: editorOrgA, GVR: gvrKeepers}) |
||||
clientOrgB := helper.GetResourceClient(apis.ResourceClientArgs{User: editorOrgB, GVR: gvrKeepers}) |
||||
|
||||
// Create
|
||||
t.Run("creating a keeper with the same name as one from another namespace does not return an error", func(t *testing.T) { |
||||
// OrgA creating a keeper with the same name from OrgB.
|
||||
testData := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") |
||||
testData.SetName(keeperOrgB.GetName()) |
||||
|
||||
raw, err := clientOrgA.Resource.Create(ctx, testData, metav1.CreateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, raw) |
||||
|
||||
// OrgA creating a keeper with the same name from OrgB.
|
||||
testData = helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") |
||||
testData.SetName(keeperOrgA.GetName()) |
||||
|
||||
raw, err = clientOrgB.Resource.Create(ctx, testData, metav1.CreateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, raw) |
||||
|
||||
require.NoError(t, clientOrgA.Resource.Delete(ctx, keeperOrgB.GetName(), metav1.DeleteOptions{})) |
||||
require.NoError(t, clientOrgB.Resource.Delete(ctx, keeperOrgA.GetName(), metav1.DeleteOptions{})) |
||||
}) |
||||
|
||||
// Read
|
||||
t.Run("fetching a keeper from another namespace returns not found", func(t *testing.T) { |
||||
var statusErr *apierrors.StatusError |
||||
|
||||
// OrgA trying to fetch keeper from OrgB.
|
||||
raw, err := clientOrgA.Resource.Get(ctx, keeperOrgB.GetName(), metav1.GetOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, raw) |
||||
require.True(t, errors.As(err, &statusErr)) |
||||
require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) |
||||
|
||||
// OrgB trying to fetch keeper from OrgA.
|
||||
raw, err = clientOrgB.Resource.Get(ctx, keeperOrgA.GetName(), metav1.GetOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, raw) |
||||
require.True(t, errors.As(err, &statusErr)) |
||||
require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) |
||||
}) |
||||
|
||||
// Update
|
||||
t.Run("updating a keeper from another namespace returns not found", func(t *testing.T) { |
||||
var statusErr *apierrors.StatusError |
||||
|
||||
// OrgA trying to update securevalue from OrgB.
|
||||
testData := helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") |
||||
testData.SetName(keeperOrgB.GetName()) |
||||
testData.Object["spec"].(map[string]any)["description"] = "New description" |
||||
|
||||
raw, err := clientOrgA.Resource.Update(ctx, testData, metav1.UpdateOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, raw) |
||||
require.True(t, errors.As(err, &statusErr)) |
||||
require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) |
||||
|
||||
// OrgB trying to update keeper from OrgA.
|
||||
testData = helper.LoadYAMLOrJSONFile("testdata/keeper-aws-generate.yaml") |
||||
testData.SetName(keeperOrgA.GetName()) |
||||
testData.Object["spec"].(map[string]any)["description"] = "New description" |
||||
|
||||
raw, err = clientOrgB.Resource.Update(ctx, testData, metav1.UpdateOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, raw) |
||||
require.True(t, errors.As(err, &statusErr)) |
||||
require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) |
||||
}) |
||||
|
||||
// Delete
|
||||
t.Run("deleting a keeper from another namespace returns an error and does not delete it", func(t *testing.T) { |
||||
var statusErr *apierrors.StatusError |
||||
|
||||
// OrgA trying to delete keeper from OrgB.
|
||||
err := clientOrgA.Resource.Delete(ctx, keeperOrgB.GetName(), metav1.DeleteOptions{}) |
||||
require.Error(t, err) |
||||
require.True(t, errors.As(err, &statusErr)) |
||||
require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) |
||||
|
||||
// Check that it still exists from the perspective of OrgB.
|
||||
raw, err := clientOrgB.Resource.Get(ctx, keeperOrgB.GetName(), metav1.GetOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, raw) |
||||
|
||||
// OrgB trying to delete keeper from OrgA.
|
||||
err = clientOrgB.Resource.Delete(ctx, keeperOrgA.GetName(), metav1.DeleteOptions{}) |
||||
require.Error(t, err) |
||||
require.Equal(t, http.StatusNotFound, int(statusErr.Status().Code)) |
||||
|
||||
// Check that it still exists from the perspective of OrgA.
|
||||
raw, err = clientOrgA.Resource.Get(ctx, keeperOrgA.GetName(), metav1.GetOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, raw) |
||||
}) |
||||
|
||||
// List
|
||||
t.Run("listing keeper from a namespace does not return the ones from another namespace", func(t *testing.T) { |
||||
// OrgA listing keeper.
|
||||
listOrgA, err := clientOrgA.Resource.List(ctx, metav1.ListOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, listOrgA) |
||||
require.Len(t, listOrgA.Items, 1) |
||||
require.Equal(t, *keeperOrgA, listOrgA.Items[0]) |
||||
|
||||
// OrgB listing keeper.
|
||||
listOrgB, err := clientOrgB.Resource.List(ctx, metav1.ListOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, listOrgB) |
||||
require.Len(t, listOrgB.Items, 1) |
||||
require.Equal(t, *keeperOrgB, listOrgB.Items[0]) |
||||
}) |
||||
}) |
||||
|
||||
t.Run("keeper actions without having required permissions", func(t *testing.T) { |
||||
// Create users on a random org without specifying secrets-related permissions.
|
||||
editorP := mustCreateUsers(t, helper, nil).Editor |
||||
|
||||
clientP := helper.GetResourceClient(apis.ResourceClientArgs{ |
||||
User: editorP, |
||||
GVR: gvrKeepers, |
||||
}) |
||||
|
||||
// GET
|
||||
rawGet, err := clientP.Resource.Get(ctx, "some-keeper", metav1.GetOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, rawGet) |
||||
|
||||
var statusGetErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusGetErr)) |
||||
require.EqualValues(t, http.StatusForbidden, statusGetErr.Status().Code) |
||||
|
||||
// LIST
|
||||
rawList, err := clientP.Resource.List(ctx, metav1.ListOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, rawList) |
||||
|
||||
var statusListErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusListErr)) |
||||
require.EqualValues(t, http.StatusForbidden, statusListErr.Status().Code) |
||||
|
||||
// CREATE
|
||||
testKeeper := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") // to pass validation before authz.
|
||||
rawCreate, err := clientP.Resource.Create(ctx, testKeeper, metav1.CreateOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, rawCreate) |
||||
|
||||
var statusCreateErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusCreateErr)) |
||||
require.EqualValues(t, http.StatusForbidden, statusCreateErr.Status().Code) |
||||
|
||||
// UPDATE
|
||||
testKeeper.SetName("test") // to pass validation before authz.
|
||||
rawUpdate, err := clientP.Resource.Update(ctx, testKeeper, metav1.UpdateOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, rawUpdate) |
||||
|
||||
var statusUpdateErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusUpdateErr)) |
||||
require.EqualValues(t, http.StatusForbidden, statusUpdateErr.Status().Code) |
||||
|
||||
// DELETE
|
||||
err = clientP.Resource.Delete(ctx, "some-keeper", metav1.DeleteOptions{}) |
||||
require.Error(t, err) |
||||
|
||||
var statusDeleteErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusDeleteErr)) |
||||
require.EqualValues(t, http.StatusForbidden, statusDeleteErr.Status().Code) |
||||
}) |
||||
|
||||
t.Run("keeper actions with permissions but with limited scope", func(t *testing.T) { |
||||
suffix := strconv.FormatInt(rand.Int64(), 10) |
||||
|
||||
// Fix the Keeper names.
|
||||
keeperName := "kp-" + suffix |
||||
testKeeper := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") |
||||
testKeeper.SetName(keeperName) |
||||
|
||||
keeperNameAnother := "kp-another-" + suffix |
||||
testKeeperAnother := helper.LoadYAMLOrJSONFile("testdata/keeper-gcp-generate.yaml") |
||||
testKeeperAnother.SetName(keeperNameAnother) |
||||
|
||||
// Fix the org ID because we will create another user with scope "all" permissions on the same org, to compare.
|
||||
orgID := rand.Int64() + 2 |
||||
|
||||
// Permissions which allow any action, but scoped actions (get, update, delete) only on `keeperName` and NO OTHER Keeper.
|
||||
scopedLimitedPermissions := map[string]ResourcePermission{ |
||||
ResourceKeepers: { |
||||
Actions: ActionsAllKeepers, |
||||
Name: keeperName, |
||||
}, |
||||
} |
||||
|
||||
// Create users (+ client) with permission to manage ONLY the Keeper `keeperName`.
|
||||
editorLimited := mustCreateUsersWithOrg(t, helper, orgID, scopedLimitedPermissions).Editor |
||||
|
||||
clientScopedLimited := helper.GetResourceClient(apis.ResourceClientArgs{ |
||||
User: editorLimited, |
||||
GVR: gvrKeepers, |
||||
}) |
||||
|
||||
// Create users (+ client) with permission to manage ANY Keepers.
|
||||
scopedAllPermissions := map[string]ResourcePermission{ |
||||
ResourceKeepers: { |
||||
Actions: ActionsAllKeepers, |
||||
Name: "*", // this or not sending a `Name` have the same effect.
|
||||
}, |
||||
} |
||||
|
||||
editorAll := mustCreateUsersWithOrg(t, helper, orgID, scopedAllPermissions).Editor |
||||
|
||||
clientScopedAll := helper.GetResourceClient(apis.ResourceClientArgs{ |
||||
User: editorAll, |
||||
GVR: gvrKeepers, |
||||
}) |
||||
|
||||
// For create, we don't have actual granular permissions, so we can use any client that has unscoped create permissions.
|
||||
// This is because when we don't know yet what the name of the resource will be, the request comes with an empty value.
|
||||
// And thus the authorizer can't do granular checks.
|
||||
t.Run("CREATE", func(t *testing.T) { |
||||
rawCreateLimited, err := clientScopedAll.Resource.Create(ctx, testKeeper, metav1.CreateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawCreateLimited) |
||||
|
||||
rawCreateLimited, err = clientScopedAll.Resource.Create(ctx, testKeeperAnother, metav1.CreateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawCreateLimited) |
||||
}) |
||||
|
||||
t.Run("READ", func(t *testing.T) { |
||||
// Retrieve `keeperName` from the limited client.
|
||||
rawGetLimited, err := clientScopedLimited.Resource.Get(ctx, keeperName, metav1.GetOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawGetLimited) |
||||
require.Equal(t, rawGetLimited.GetUID(), rawGetLimited.GetUID()) |
||||
|
||||
// Retrieve `keeperName` from the scope-all client.
|
||||
rawGetAll, err := clientScopedAll.Resource.Get(ctx, keeperName, metav1.GetOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawGetAll) |
||||
require.Equal(t, rawGetAll.GetUID(), rawGetLimited.GetUID()) |
||||
|
||||
// Even though we can create it, we cannot retrieve `keeperNameAnother` from the limited client.
|
||||
rawGetLimited, err = clientScopedLimited.Resource.Get(ctx, keeperNameAnother, metav1.GetOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, rawGetLimited) |
||||
|
||||
var statusGetErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusGetErr)) |
||||
require.EqualValues(t, http.StatusForbidden, statusGetErr.Status().Code) |
||||
|
||||
// Retrieve `keeperNameAnother` from the scope-all client.
|
||||
rawGetAll, err = clientScopedAll.Resource.Get(ctx, keeperNameAnother, metav1.GetOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawGetAll) |
||||
}) |
||||
|
||||
t.Run("LIST", func(t *testing.T) { |
||||
// List Keepers from the limited client should return only 1.
|
||||
rawList, err := clientScopedLimited.Resource.List(ctx, metav1.ListOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawList) |
||||
require.Len(t, rawList.Items, 1) |
||||
require.Equal(t, keeperName, rawList.Items[0].GetName()) |
||||
|
||||
// List Keepers from the scope-all client should return all of them.
|
||||
rawList, err = clientScopedAll.Resource.List(ctx, metav1.ListOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawList) |
||||
require.Len(t, rawList.Items, 2) |
||||
}) |
||||
|
||||
t.Run("UPDATE", func(t *testing.T) { |
||||
// Update `keeperName` from the limited client.
|
||||
testKeeperUpdate := testKeeper.DeepCopy() |
||||
testKeeperUpdate.Object["spec"].(map[string]any)["description"] = "keeper-description-1234" |
||||
|
||||
rawUpdate, err := clientScopedLimited.Resource.Update(ctx, testKeeperUpdate, metav1.UpdateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawUpdate) |
||||
|
||||
// Try to update `keeperNameAnother` from the limited client.
|
||||
testKeeperAnotherUpdate := testKeeperAnother.DeepCopy() |
||||
testKeeperAnotherUpdate.Object["spec"].(map[string]any)["description"] = "keeper-description-5678" |
||||
|
||||
rawUpdate, err = clientScopedLimited.Resource.Update(ctx, testKeeperAnotherUpdate, metav1.UpdateOptions{}) |
||||
require.Error(t, err) |
||||
require.Nil(t, rawUpdate) |
||||
|
||||
var statusUpdateErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusUpdateErr)) |
||||
require.EqualValues(t, http.StatusForbidden, statusUpdateErr.Status().Code) |
||||
|
||||
// Update `keeperNameAnother` from the scope-all client.
|
||||
rawUpdate, err = clientScopedAll.Resource.Update(ctx, testKeeperAnotherUpdate, metav1.UpdateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, rawUpdate) |
||||
}) |
||||
|
||||
// Keep this last for cleaning up the resources.
|
||||
t.Run("DELETE", func(t *testing.T) { |
||||
// Try to delete `keeperNameAnother` from the limited client.
|
||||
err := clientScopedLimited.Resource.Delete(ctx, keeperNameAnother, metav1.DeleteOptions{}) |
||||
require.Error(t, err) |
||||
|
||||
var statusDeleteErr *apierrors.StatusError |
||||
require.True(t, errors.As(err, &statusDeleteErr)) |
||||
require.EqualValues(t, http.StatusForbidden, statusDeleteErr.Status().Code) |
||||
|
||||
// Delete `keeperNameAnother` from the scope-all client.
|
||||
err = clientScopedAll.Resource.Delete(ctx, keeperNameAnother, metav1.DeleteOptions{}) |
||||
require.NoError(t, err) |
||||
|
||||
// Delete `keeperName` from the limited client.
|
||||
err = clientScopedLimited.Resource.Delete(ctx, keeperName, metav1.DeleteOptions{}) |
||||
require.NoError(t, err) |
||||
}) |
||||
}) |
||||
} |
@ -0,0 +1,150 @@ |
||||
package secret |
||||
|
||||
import ( |
||||
"cmp" |
||||
"context" |
||||
"encoding/json" |
||||
"math/rand/v2" |
||||
"strconv" |
||||
"testing" |
||||
|
||||
"github.com/grafana/grafana/pkg/registry/apis/secret" |
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" |
||||
"github.com/grafana/grafana/pkg/services/featuremgmt" |
||||
"github.com/grafana/grafana/pkg/services/org" |
||||
"github.com/grafana/grafana/pkg/services/team" |
||||
"github.com/grafana/grafana/pkg/tests/apis" |
||||
"github.com/grafana/grafana/pkg/tests/testinfra" |
||||
"github.com/grafana/grafana/pkg/tests/testsuite" |
||||
"github.com/stretchr/testify/require" |
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
||||
) |
||||
|
||||
var ( |
||||
ResourceSecureValues = "secret.securevalues" |
||||
ResourceKeepers = "secret.keepers" |
||||
|
||||
ActionsAllKeepers = []string{ |
||||
secret.ActionSecretKeepersCreate, |
||||
secret.ActionSecretKeepersWrite, |
||||
secret.ActionSecretKeepersRead, |
||||
secret.ActionSecretKeepersDelete, |
||||
} |
||||
ActionsAllSecureValues = []string{ |
||||
secret.ActionSecretSecureValuesCreate, |
||||
secret.ActionSecretSecureValuesWrite, |
||||
secret.ActionSecretSecureValuesRead, |
||||
secret.ActionSecretSecureValuesDelete, |
||||
} |
||||
) |
||||
|
||||
type ResourcePermission struct { |
||||
Actions []string |
||||
Name string // empty or "*" for all
|
||||
} |
||||
|
||||
func TestMain(m *testing.M) { |
||||
testsuite.Run(m) |
||||
} |
||||
|
||||
func TestIntegrationDiscoveryClient(t *testing.T) { |
||||
if testing.Short() { |
||||
t.Skip("skipping integration test") |
||||
} |
||||
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ |
||||
AppModeProduction: false, // required for experimental APIs
|
||||
EnableFeatureToggles: []string{ |
||||
// Required to start the example service
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, |
||||
featuremgmt.FlagSecretsManagementAppPlatform, |
||||
}, |
||||
}) |
||||
|
||||
t.Run("check discovery client", func(t *testing.T) { |
||||
disco := helper.NewDiscoveryClient() |
||||
|
||||
resources, err := disco.ServerResourcesForGroupVersion("secret.grafana.app/v0alpha1") |
||||
require.NoError(t, err) |
||||
|
||||
v1Disco, err := json.MarshalIndent(resources, "", " ") |
||||
require.NoError(t, err) |
||||
|
||||
var apiResourceList map[string]any |
||||
require.NoError(t, json.Unmarshal(v1Disco, &apiResourceList)) |
||||
|
||||
groupVersion, ok := apiResourceList["groupVersion"].(string) |
||||
require.True(t, ok) |
||||
require.Equal(t, "secret.grafana.app/v0alpha1", groupVersion) |
||||
|
||||
apiResources, ok := apiResourceList["resources"].([]any) |
||||
require.True(t, ok) |
||||
require.Len(t, apiResources, 2) // securevalue + keeper + (subresources...)
|
||||
}) |
||||
} |
||||
|
||||
func mustCreateUsersWithOrg(t *testing.T, helper *apis.K8sTestHelper, orgID int64, permissionMap map[string]ResourcePermission) apis.OrgUsers { |
||||
t.Helper() |
||||
|
||||
permissions := make([]resourcepermissions.SetResourcePermissionCommand, 0, len(permissionMap)) |
||||
for resource, permission := range permissionMap { |
||||
permissions = append(permissions, resourcepermissions.SetResourcePermissionCommand{ |
||||
Actions: permission.Actions, |
||||
Resource: resource, |
||||
ResourceAttribute: "uid", |
||||
ResourceID: cmp.Or(permission.Name, "*"), |
||||
}) |
||||
} |
||||
|
||||
orgName := "org-" + strconv.FormatInt(orgID, 10) |
||||
|
||||
userSuffix := strconv.FormatInt(rand.Int64(), 10) |
||||
|
||||
// Add here admin or viewer if necessary.
|
||||
editor := helper.CreateUser("editor-"+userSuffix, orgName, org.RoleEditor, permissions) |
||||
|
||||
staff := helper.CreateTeam("staff-"+userSuffix, "staff-"+userSuffix+"@"+orgName, editor.Identity.GetOrgID()) |
||||
|
||||
// Also call this method for each new user.
|
||||
helper.AddOrUpdateTeamMember(editor, staff.ID, team.PermissionTypeMember) |
||||
|
||||
return apis.OrgUsers{ |
||||
Editor: editor, |
||||
Staff: staff, |
||||
} |
||||
} |
||||
|
||||
func mustCreateUsers(t *testing.T, helper *apis.K8sTestHelper, permissionMap map[string]ResourcePermission) apis.OrgUsers { |
||||
orgID := rand.Int64() + 2 // if it is 0, becomes 2 and not 1.
|
||||
return mustCreateUsersWithOrg(t, helper, orgID, permissionMap) |
||||
} |
||||
|
||||
func mustGenerateKeeper(t *testing.T, helper *apis.K8sTestHelper, user apis.User, specType map[string]any, testFile string) *unstructured.Unstructured { |
||||
t.Helper() |
||||
|
||||
require.NotEmpty(t, testFile, "testFile must not be empty") |
||||
|
||||
ctx, cancel := context.WithCancel(context.Background()) |
||||
t.Cleanup(cancel) |
||||
|
||||
keeperClient := helper.GetResourceClient(apis.ResourceClientArgs{ |
||||
User: user, |
||||
GVR: gvrKeepers, |
||||
}) |
||||
|
||||
testKeeper := helper.LoadYAMLOrJSONFile(testFile) |
||||
if specType != nil { |
||||
testKeeper.Object["spec"] = specType |
||||
} |
||||
|
||||
raw, err := keeperClient.Resource.Create(ctx, testKeeper, metav1.CreateOptions{}) |
||||
require.NoError(t, err) |
||||
require.NotNil(t, raw) |
||||
|
||||
t.Cleanup(func() { |
||||
require.NoError(t, keeperClient.Resource.Delete(ctx, raw.GetName(), metav1.DeleteOptions{})) |
||||
}) |
||||
|
||||
return raw |
||||
} |
@ -0,0 +1,15 @@ |
||||
apiVersion: secret.grafana.app/v0alpha1 |
||||
kind: Keeper |
||||
metadata: |
||||
annotations: |
||||
xx: XXX |
||||
labels: |
||||
aa: AAA |
||||
spec: |
||||
description: AWS XYZ value |
||||
aws: |
||||
accessKeyId: |
||||
valueFromEnv: ACCESS_KEY_ID_XYZ |
||||
secretAccessKey: |
||||
valueFromEnv: SECRET_ACCESS_KEY_XYZ |
||||
kmsKeyId: kmsKeyId-xyz |
@ -0,0 +1,14 @@ |
||||
apiVersion: secret.grafana.app/v0alpha1 |
||||
kind: Keeper |
||||
metadata: |
||||
annotations: |
||||
xx: XXX |
||||
yy: YYY |
||||
labels: |
||||
aa: AAA |
||||
bb: BBB |
||||
spec: |
||||
description: GCP XYZ value |
||||
gcp: |
||||
projectId: project-id |
||||
credentialsFile: /path/to/file.json |
Loading…
Reference in new issue