Secrets: Only register dependencies to start up (#107504)

pull/107511/head
Matheus Macabu 3 weeks ago committed by GitHub
parent d55541735a
commit 7614089077
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      .github/CODEOWNERS
  2. 2
      pkg/registry/apis/apis.go
  3. 10
      pkg/registry/apis/secret/accesscontrol.go
  4. 262
      pkg/registry/apis/secret/register.go
  5. 386
      pkg/registry/apis/secret/reststorage/keeper_rest.go
  6. 276
      pkg/registry/apis/secret/reststorage/keeper_rest_test.go
  7. 379
      pkg/registry/apis/secret/reststorage/secure_value_rest.go
  8. 308
      pkg/registry/apis/secret/reststorage/secure_value_rest_test.go
  9. 4
      pkg/registry/apis/wireset.go
  10. 42
      pkg/server/wire_gen.go
  11. 567
      pkg/tests/apis/secret/keeper_test.go
  12. 150
      pkg/tests/apis/secret/main_test.go
  13. 15
      pkg/tests/apis/secret/testdata/keeper-aws-generate.yaml
  14. 14
      pkg/tests/apis/secret/testdata/keeper-gcp-generate.yaml
  15. 15
      pkg/tests/apis/secret/testdata/secure-value-default-generate.yaml
  16. 16
      pkg/tests/apis/secret/testdata/secure-value-generate.yaml

@ -180,7 +180,6 @@
/pkg/tests/apis/query @grafana/grafana-datasources-core-services
/pkg/tests/apis/alerting @grafana/grafana-app-platform-squad @grafana/alerting-backend
/pkg/tests/api/correlations/ @grafana/datapro
/pkg/tests/apis/secret/ @grafana/grafana-operator-experience-squad
/pkg/tsdb/grafanads/ @grafana/grafana-backend-group
/pkg/tsdb/opentsdb/ @grafana/partner-datasources
/pkg/util/ @grafana/grafana-backend-group

@ -27,9 +27,9 @@ func ProvideRegistryServiceSink(
_ *iam.IdentityAccessManagementAPIBuilder,
_ *query.QueryAPIBuilder,
_ *userstorage.UserStorageAPIBuilder,
_ *secret.SecretAPIBuilder,
_ *provisioning.APIBuilder,
_ *ofrep.APIBuilder,
_ *secret.DependencyRegisterer,
) *Service {
return &Service{}
}

@ -27,12 +27,12 @@ var (
ScopeAllKeepers = ScopeProviderSecretKeepers.GetResourceAllScope()
)
func RegisterAccessControlRoles(service accesscontrol.Service) error {
func registerAccessControlRoles(service accesscontrol.Service) error {
// SecureValues
secureValuesReader := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:secret.securevalues:reader",
DisplayName: "Secrets Manager secure values reader",
DisplayName: "Secure Values Reader",
Description: "Read and list secure values.",
Group: "Secrets Manager",
Permissions: []accesscontrol.Permission{
@ -48,7 +48,7 @@ func RegisterAccessControlRoles(service accesscontrol.Service) error {
secureValuesWriter := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:secret.securevalues:writer",
DisplayName: "Secrets Manager secure values writer",
DisplayName: "Secure Values Writer",
Description: "Create, update and delete secure values.",
Group: "Secrets Manager",
Permissions: []accesscontrol.Permission{
@ -77,7 +77,7 @@ func RegisterAccessControlRoles(service accesscontrol.Service) error {
keepersReader := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:secret.keepers:reader",
DisplayName: "Secrets Manager keepers reader",
DisplayName: "Keepers Reader",
Description: "Read and list keepers.",
Group: "Secrets Manager",
Permissions: []accesscontrol.Permission{
@ -93,7 +93,7 @@ func RegisterAccessControlRoles(service accesscontrol.Service) error {
keepersWriter := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:secret.keepers:writer",
DisplayName: "Secrets Manager keepers writer",
DisplayName: "Keepers Writer",
Description: "Create, update and delete keepers.",
Group: "Secrets Manager",
Permissions: []accesscontrol.Permission{

@ -4,268 +4,40 @@ import (
"context"
"fmt"
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"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
"k8s.io/kube-openapi/pkg/common"
claims "github.com/grafana/authlib/types"
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/reststorage"
"github.com/grafana/grafana/pkg/services/accesscontrol"
authsvc "github.com/grafana/grafana/pkg/services/apiserver/auth/authorizer"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
var (
_ builder.APIGroupBuilder = (*SecretAPIBuilder)(nil)
_ builder.APIGroupMutation = (*SecretAPIBuilder)(nil)
_ builder.APIGroupValidation = (*SecretAPIBuilder)(nil)
)
// DependencyRegisterer is set to satisfy wire gen and make sure the `RegisterDependencies` is called.
type DependencyRegisterer struct{}
type SecretAPIBuilder struct {
tracer tracing.Tracer
secureValueMetadataStorage contracts.SecureValueMetadataStorage
keeperMetadataStorage contracts.KeeperMetadataStorage
accessClient claims.AccessClient
decryptersAllowList map[string]struct{}
}
func NewSecretAPIBuilder(
tracer tracing.Tracer,
secureValueMetadataStorage contracts.SecureValueMetadataStorage,
keeperMetadataStorage contracts.KeeperMetadataStorage,
accessClient claims.AccessClient,
decryptersAllowList map[string]struct{},
) *SecretAPIBuilder {
return &SecretAPIBuilder{tracer, secureValueMetadataStorage, keeperMetadataStorage, accessClient, decryptersAllowList}
}
func RegisterAPIService(
func RegisterDependencies(
features featuremgmt.FeatureToggles,
cfg *setting.Cfg,
apiregistration builder.APIRegistrar,
tracer tracing.Tracer,
secureValueMetadataStorage contracts.SecureValueMetadataStorage,
keeperMetadataStorage contracts.KeeperMetadataStorage,
accessClient claims.AccessClient,
accessControlService accesscontrol.Service,
secretDBMigrator contracts.SecretDBMigrator,
) (*SecretAPIBuilder, error) {
// Skip registration unless opting into experimental apis and the secrets management app platform flag.
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) ||
!features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) {
accessControlService accesscontrol.Service,
) (*DependencyRegisterer, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) || !features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) {
return nil, nil
}
// Some DBs that claim to be MySQL/Postgres-compatible might not support table locking.
lockDatabase := cfg.Raw.Section("database").Key("migration_locking").MustBool(true)
if err := secretDBMigrator.RunMigrations(context.Background(), lockDatabase); err != nil {
return nil, fmt.Errorf("running secret database migrations: %w", err)
}
if err := RegisterAccessControlRoles(accessControlService); err != nil {
return nil, fmt.Errorf("register secret access control roles: %w", err)
}
builder := NewSecretAPIBuilder(
tracer,
secureValueMetadataStorage,
keeperMetadataStorage,
accessClient,
nil, // OSS does not need an allow list.
)
apiregistration.RegisterAPI(builder)
return builder, nil
}
// GetGroupVersion returns the tuple of `group` and `version` for the API which uniquely identifies it.
func (b *SecretAPIBuilder) GetGroupVersion() schema.GroupVersion {
return secretv0alpha1.SchemeGroupVersion
}
// InstallSchema is called by the `apiserver` which exposes the defined kinds.
func (b *SecretAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
err := secretv0alpha1.AddKnownTypes(scheme, secretv0alpha1.VERSION)
if err != nil {
return err
}
// Link this version to the internal representation.
// This is used for server-side-apply (PATCH), and avoids the error:
// "no kind is registered for the type"
err = secretv0alpha1.AddKnownTypes(scheme, runtime.APIVersionInternal)
if err != nil {
return err
}
// Internal Kubernetes metadata API. Presumably to display the available APIs?
// e.g. http://localhost:3000/apis/secret.grafana.app/v0alpha1
metav1.AddToGroupVersion(scheme, secretv0alpha1.SchemeGroupVersion)
// This sets the priority in case we have multiple versions.
// By default Kubernetes will only let you use `kubectl get <resource>` with one version.
// In case there are multiple versions, we'd need to pass the full path with the `--raw` flag.
if err := scheme.SetVersionPriority(secretv0alpha1.SchemeGroupVersion); err != nil {
return fmt.Errorf("scheme set version priority: %w", err)
}
return nil
}
func (b *SecretAPIBuilder) AllowedV0Alpha1Resources() []string {
return nil
}
// UpdateAPIGroupInfo is called when creating a generic API server for this group of kinds.
func (b *SecretAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
secureValueResource := secretv0alpha1.SecureValuesResourceInfo
keeperResource := secretv0alpha1.KeeperResourceInfo
// rest.Storage is a generic interface for RESTful storage services.
// The constructors need to at least implement this interface, but will most likely implement
// other interfaces that equal to different operations like `get`, `list` and so on.
secureRestStorage := map[string]rest.Storage{
// Default path for `securevalue`.
// The `reststorage.SecureValueRest` struct will implement interfaces for CRUDL operations on `securevalue`.
secureValueResource.StoragePath(): reststorage.NewSecureValueRest(b.secureValueMetadataStorage, secureValueResource),
// The `reststorage.KeeperRest` struct will implement interfaces for CRUDL operations on `keeper`.
keeperResource.StoragePath(): reststorage.NewKeeperRest(b.keeperMetadataStorage, b.accessClient, keeperResource),
}
apiGroupInfo.VersionedResourcesStorageMap[secretv0alpha1.VERSION] = secureRestStorage
return nil
}
// GetOpenAPIDefinitions, is this only for documentation?
func (b *SecretAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return secretv0alpha1.GetOpenAPIDefinitions
}
// GetAuthorizer decides whether the request is allowed, denied or no opinion based on credentials and request attributes.
// Usually most resource are stored in folders (e.g. alerts, dashboards), which allows users to manage permissions at folder level,
// rather than at resource level which also has the benefit of lowering the load on AuthZ side, since instead of storing access to
// a single dashboard, you'd store access to all dashboards in a specific folder.
// For Secrets, this is not the case, but if we want to make it so, we need to update this ResourceAuthorizer to check the containing folder.
// If we ever want to do that, get guidance from IAM first as well.
func (b *SecretAPIBuilder) GetAuthorizer() authorizer.Authorizer {
return authsvc.NewResourceAuthorizer(b.accessClient)
}
// Validate is called in `Create`, `Update` and `Delete` REST funcs, if the body calls the argument `rest.ValidateObjectFunc`.
func (b *SecretAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
obj := a.GetObject()
operation := a.GetOperation()
if obj == nil || operation == admission.Connect {
return nil // This is normal for sub-resource
}
groupKind := obj.GetObjectKind().GroupVersionKind().GroupKind()
// Generic validations for all kinds. At this point the name+namespace must not be empty.
if a.GetName() == "" {
return apierrors.NewInvalid(
groupKind,
a.GetName(),
field.ErrorList{field.Required(field.NewPath("metadata", "name"), "a `name` is required")},
)
}
if a.GetNamespace() == "" {
return apierrors.NewInvalid(
groupKind,
a.GetName(),
field.ErrorList{field.Required(field.NewPath("metadata", "namespace"), "a `namespace` is required")},
)
}
switch typedObj := obj.(type) {
case *secretv0alpha1.SecureValue:
var oldObj *secretv0alpha1.SecureValue
if a.GetOldObject() != nil {
var ok bool
oldObj, ok = a.GetOldObject().(*secretv0alpha1.SecureValue)
if !ok {
return apierrors.NewBadRequest(fmt.Sprintf("old object is not a SecureValue, found %T", a.GetOldObject()))
}
}
if errs := reststorage.ValidateSecureValue(typedObj, oldObj, operation, b.decryptersAllowList); len(errs) > 0 {
return apierrors.NewInvalid(groupKind, a.GetName(), errs)
}
return nil
case *secretv0alpha1.Keeper:
if errs := reststorage.ValidateKeeper(typedObj, operation); len(errs) > 0 {
return apierrors.NewInvalid(groupKind, a.GetName(), errs)
}
return nil
// Permissions for requests in multi-tenant mode will come from HG.
if err := registerAccessControlRoles(accessControlService); err != nil {
return nil, fmt.Errorf("registering access control roles: %w", err)
}
return apierrors.NewBadRequest(fmt.Sprintf("unknown spec %T", obj))
}
func (b *SecretAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
obj := a.GetObject()
operation := a.GetOperation()
if obj == nil || operation == admission.Connect {
return nil // This is normal for sub-resource
}
// When creating a resource and the name is empty, we need to generate one.
if operation == admission.Create && a.GetName() == "" {
generatedName, err := util.GetRandomString(8)
if err != nil {
return fmt.Errorf("generate random string: %w", err)
}
switch typedObj := obj.(type) {
case *secretv0alpha1.SecureValue:
optionalPrefix := typedObj.GenerateName
if optionalPrefix == "" {
optionalPrefix = "sv-"
}
typedObj.Name = optionalPrefix + generatedName
case *secretv0alpha1.Keeper:
optionalPrefix := typedObj.GenerateName
if optionalPrefix == "" {
optionalPrefix = "kp-"
}
typedObj.Name = optionalPrefix + generatedName
}
}
// We shouldn't need to create the DB in HG, as that will use the MT api server.
if cfg.StackID == "" {
// Some DBs that claim to be MySQL/Postgres-compatible might not support table locking.
lockDatabase := cfg.Raw.Section("database").Key("migration_locking").MustBool(true)
// On any mutation to a `SecureValue`, override the `phase` as `Pending` and an empty `message`.
if operation == admission.Create || operation == admission.Update {
sv, ok := obj.(*secretv0alpha1.SecureValue)
if ok && sv != nil {
sv.Status.Phase = secretv0alpha1.SecureValuePhasePending
sv.Status.Message = ""
// This is needed to wire up and run DB migrations for Secrets Manager, which is not run by the generic OSS DB migrator.
if err := secretDBMigrator.RunMigrations(context.Background(), lockDatabase); err != nil {
return nil, fmt.Errorf("running secret database migrations: %w", err)
}
}
return nil
return &DependencyRegisterer{}, nil
}

@ -1,386 +0,0 @@
package reststorage
import (
"context"
"errors"
"fmt"
"strings"
claims "github.com/grafana/authlib/types"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apimachinery/utils"
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"
)
var (
_ rest.Scoper = (*KeeperRest)(nil)
_ rest.SingularNameProvider = (*KeeperRest)(nil)
_ rest.Getter = (*KeeperRest)(nil)
_ rest.Lister = (*KeeperRest)(nil)
_ rest.Storage = (*KeeperRest)(nil)
_ rest.Creater = (*KeeperRest)(nil)
_ rest.Updater = (*KeeperRest)(nil)
_ rest.GracefulDeleter = (*KeeperRest)(nil)
)
// KeeperRest is an implementation of CRUDL operations on a `keeper` backed by TODO.
type KeeperRest struct {
storage contracts.KeeperMetadataStorage
accessClient claims.AccessClient
resource utils.ResourceInfo
tableConverter rest.TableConvertor
}
// NewKeeperRest is a returns a constructed `*KeeperRest`.
func NewKeeperRest(storage contracts.KeeperMetadataStorage, accessClient claims.AccessClient, resource utils.ResourceInfo) *KeeperRest {
return &KeeperRest{storage, accessClient, resource, resource.TableConverter()}
}
// New returns an empty `*Keeper` that is used by the `Create` method.
func (s *KeeperRest) New() runtime.Object {
return s.resource.NewFunc()
}
// Destroy is called when? [TODO]
func (s *KeeperRest) Destroy() {}
// NamespaceScoped returns `true` because the storage is namespaced (== org).
func (s *KeeperRest) NamespaceScoped() bool {
return true
}
// GetSingularName is used by `kubectl` discovery to have singular name representation of resources.
func (s *KeeperRest) GetSingularName() string {
return s.resource.GetSingularName()
}
// NewList returns an empty `*KeeperList` that is used by the `List` method.
func (s *KeeperRest) NewList() runtime.Object {
return s.resource.NewListFunc()
}
// ConvertToTable is used by Kubernetes and converts objects to `metav1.Table`.
func (s *KeeperRest) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
// List calls the inner `store` (persistence) and returns a list of `Keepers` within a `namespace` filtered by the `options`.
func (s *KeeperRest) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
namespace, ok := request.NamespaceFrom(ctx)
if !ok {
return nil, fmt.Errorf("missing namespace")
}
user, ok := claims.AuthInfoFrom(ctx)
if !ok {
return nil, fmt.Errorf("missing auth info in context")
}
hasPermissionFor, err := s.accessClient.Compile(ctx, user, claims.ListRequest{
Group: secretv0alpha1.GROUP,
Resource: secretv0alpha1.KeeperResourceInfo.GetName(),
Namespace: namespace,
Verb: utils.VerbGet,
})
if err != nil {
return nil, fmt.Errorf("failed to compile checker: %w", err)
}
labelSelector := options.LabelSelector
if labelSelector == nil {
labelSelector = labels.Everything()
}
keepersList, err := s.storage.List(ctx, xkube.Namespace(namespace))
if err != nil {
return nil, fmt.Errorf("failed to list keepers: %w", err)
}
allowedKeepers := make([]secretv0alpha1.Keeper, 0)
for _, keeper := range keepersList {
// Check whether the user has permission to access this specific Keeper in the namespace.
if !hasPermissionFor(keeper.Name, "") {
continue
}
if labelSelector.Matches(labels.Set(keeper.Labels)) {
allowedKeepers = append(allowedKeepers, keeper)
}
}
return &secretv0alpha1.KeeperList{
Items: allowedKeepers,
}, nil
}
// Get calls the inner `store` (persistence) and returns a `Keeper` by `name`.
func (s *KeeperRest) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
namespace, ok := request.NamespaceFrom(ctx)
if !ok {
return nil, fmt.Errorf("missing namespace")
}
// TODO: readopts
kp, err := s.storage.Read(ctx, xkube.Namespace(namespace), name, contracts.ReadOpts{})
if err != nil {
if errors.Is(err, contracts.ErrKeeperNotFound) {
return nil, s.resource.NewNotFound(name)
}
return nil, fmt.Errorf("failed to read keeper: %w", err)
}
return kp, nil
}
// Create a new `Keeper`. Does some validation and allows empty `name` (generated).
func (s *KeeperRest) Create(
ctx context.Context,
obj runtime.Object,
createValidation rest.ValidateObjectFunc,
options *metav1.CreateOptions,
) (runtime.Object, error) {
kp, ok := obj.(*secretv0alpha1.Keeper)
if !ok {
return nil, fmt.Errorf("expected Keeper for create")
}
user, ok := claims.AuthInfoFrom(ctx)
if !ok {
return nil, fmt.Errorf("missing auth info in context")
}
if err := createValidation(ctx, obj); err != nil {
return nil, err
}
createdKeeper, err := s.storage.Create(ctx, kp, user.GetUID())
if err != nil {
var kErr xkube.ErrorLister
if errors.As(err, &kErr) {
return nil, apierrors.NewInvalid(kp.GroupVersionKind().GroupKind(), kp.Name, kErr.ErrorList())
}
return nil, fmt.Errorf("failed to create keeper: %w", err)
}
return createdKeeper, nil
}
// Update a `Keeper`'s `value`. The second return parameter indicates whether the resource was newly created.
func (s *KeeperRest) Update(
ctx context.Context,
name string,
objInfo rest.UpdatedObjectInfo,
createValidation rest.ValidateObjectFunc,
updateValidation rest.ValidateObjectUpdateFunc,
forceAllowCreate bool,
options *metav1.UpdateOptions,
) (runtime.Object, bool, error) {
user, ok := claims.AuthInfoFrom(ctx)
if !ok {
return nil, false, fmt.Errorf("missing auth info in context")
}
oldObj, err := s.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, false, err
}
// Makes sure the UID and ResourceVersion are OK.
// TODO: this also makes it so the labels and annotations are additive, unless we check and remove manually.
newObj, err := objInfo.UpdatedObject(ctx, oldObj)
if err != nil {
return nil, false, fmt.Errorf("k8s updated object: %w", err)
}
// The current supported behavior for `Update` is to replace the entire `spec` with the new one.
// Each provider-specific setting of a keeper lives at the top-level, so it makes it possible to change a provider
// during an update. Otherwise both old and new providers would be merged in the `newObj` which is not allowed.
if err := updateValidation(ctx, newObj, oldObj); err != nil {
return nil, false, err
}
newKeeper, ok := newObj.(*secretv0alpha1.Keeper)
if !ok {
return nil, false, fmt.Errorf("expected Keeper for update")
}
// TODO: do we need to do this here again? Probably not, but double-check!
newKeeper.Annotations = xkube.CleanAnnotations(newKeeper.Annotations)
// Current implementation replaces everything passed in the spec, so it is not a PATCH. Do we want/need to support that?
updatedKeeper, err := s.storage.Update(ctx, newKeeper, user.GetUID())
if err != nil {
var kErr xkube.ErrorLister
if errors.As(err, &kErr) {
return nil, false, apierrors.NewInvalid(newKeeper.GroupVersionKind().GroupKind(), newKeeper.Name, kErr.ErrorList())
}
return nil, false, fmt.Errorf("failed to update keeper: %w", err)
}
return updatedKeeper, false, nil
}
// Delete calls the inner `store` (persistence) in order to delete the `Keeper`.
// The second return parameter `bool` indicates whether the delete was intant or not. It always is for `Keepers`.
func (s *KeeperRest) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
namespace, ok := request.NamespaceFrom(ctx)
if !ok {
return nil, false, fmt.Errorf("missing namespace")
}
err := s.storage.Delete(ctx, xkube.Namespace(namespace), name)
if err != nil {
if errors.Is(err, contracts.ErrKeeperNotFound) {
return nil, false, s.resource.NewNotFound(name)
}
return nil, false, fmt.Errorf("failed to delete keeper: %w", err)
}
return nil, true, nil
}
// ValidateKeeper does basic spec validation of a keeper.
func ValidateKeeper(keeper *secretv0alpha1.Keeper, operation admission.Operation) field.ErrorList {
// Only validate Create and Update for now.
if operation != admission.Create && operation != admission.Update {
return nil
}
errs := make(field.ErrorList, 0)
if keeper.Spec.Description == "" {
errs = append(errs, field.Required(field.NewPath("spec", "description"), "a `description` is required"))
}
// Only one keeper type can be configured. Return early and don't validate the specific keeper fields.
if err := validateKeepers(keeper); err != nil {
errs = append(errs, err)
return errs
}
if keeper.Spec.AWS != nil {
if err := validateCredentialValue(field.NewPath("spec", "aws", "accessKeyId"), keeper.Spec.AWS.AccessKeyID); err != nil {
errs = append(errs, err)
}
if err := validateCredentialValue(field.NewPath("spec", "aws", "secretAccessKey"), keeper.Spec.AWS.SecretAccessKey); err != nil {
errs = append(errs, err)
}
}
if keeper.Spec.Azure != nil {
if keeper.Spec.Azure.KeyVaultName == "" {
errs = append(errs, field.Required(field.NewPath("spec", "azure", "keyVaultName"), "a `keyVaultName` is required"))
}
if keeper.Spec.Azure.TenantID == "" {
errs = append(errs, field.Required(field.NewPath("spec", "azure", "tenantId"), "a `tenantId` is required"))
}
if keeper.Spec.Azure.ClientID == "" {
errs = append(errs, field.Required(field.NewPath("spec", "azure", "clientId"), "a `clientId` is required"))
}
if err := validateCredentialValue(field.NewPath("spec", "azure", "clientSecret"), keeper.Spec.Azure.ClientSecret); err != nil {
errs = append(errs, err)
}
}
if keeper.Spec.GCP != nil {
if keeper.Spec.GCP.ProjectID == "" {
errs = append(errs, field.Required(field.NewPath("spec", "gcp", "projectId"), "a `projectId` is required"))
}
if keeper.Spec.GCP.CredentialsFile == "" {
errs = append(errs, field.Required(field.NewPath("spec", "gcp", "credentialsFile"), "a `credentialsFile` is required"))
}
}
if keeper.Spec.HashiCorp != nil {
if keeper.Spec.HashiCorp.Address == "" {
errs = append(errs, field.Required(field.NewPath("spec", "hashicorp", "address"), "a `address` is required"))
}
if err := validateCredentialValue(field.NewPath("spec", "hashicorp", "token"), keeper.Spec.HashiCorp.Token); err != nil {
errs = append(errs, err)
}
}
return errs
}
func validateKeepers(keeper *secretv0alpha1.Keeper) *field.Error {
availableKeepers := map[string]bool{
"aws": keeper.Spec.AWS != nil,
"azure": keeper.Spec.Azure != nil,
"gcp": keeper.Spec.GCP != nil,
"hashicorp": keeper.Spec.HashiCorp != nil,
}
configuredKeepers := make([]string, 0)
for keeperKind, notNil := range availableKeepers {
if notNil {
configuredKeepers = append(configuredKeepers, keeperKind)
}
}
if len(configuredKeepers) == 0 {
return field.Required(field.NewPath("spec"), "at least one `keeper` must be present")
}
if len(configuredKeepers) > 1 {
return field.Invalid(
field.NewPath("spec"),
strings.Join(configuredKeepers, " & "),
"only one `keeper` can be present at a time but found more",
)
}
return nil
}
func validateCredentialValue(path *field.Path, credentials secretv0alpha1.CredentialValue) *field.Error {
availableOptions := map[string]bool{
"secureValueName": credentials.SecureValueName != "",
"valueFromEnv": credentials.ValueFromEnv != "",
"valueFromConfig": credentials.ValueFromConfig != "",
}
configuredCredentials := make([]string, 0)
for credentialKind, notEmpty := range availableOptions {
if notEmpty {
configuredCredentials = append(configuredCredentials, credentialKind)
}
}
if len(configuredCredentials) == 0 {
return field.Required(path, "one of `secureValueName`, `valueFromEnv` or `valueFromConfig` must be present")
}
if len(configuredCredentials) > 1 {
return field.Invalid(
path,
strings.Join(configuredCredentials, " & "),
"only one of `secureValueName`, `valueFromEnv` or `valueFromConfig` must be present at a time but found more",
)
}
return nil
}

@ -1,276 +0,0 @@
package reststorage
import (
"testing"
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
"github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/admission"
)
func TestValidateKeeper(t *testing.T) {
t.Run("when creating a new keeper", func(t *testing.T) {
t.Run("the `description` must be present", func(t *testing.T) {
keeper := &secretv0alpha1.Keeper{
Spec: secretv0alpha1.KeeperSpec{
AWS: &secretv0alpha1.AWSKeeperConfig{
AWSCredentials: secretv0alpha1.AWSCredentials{
AccessKeyID: secretv0alpha1.CredentialValue{ValueFromEnv: "some-value"},
SecretAccessKey: secretv0alpha1.CredentialValue{ValueFromEnv: "some-value"},
KMSKeyID: "kms-key-id",
},
},
},
}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.description", errs[0].Field)
})
})
t.Run("only one `keeper` must be present", func(t *testing.T) {
keeper := &secretv0alpha1.Keeper{
Spec: secretv0alpha1.KeeperSpec{
Description: "short description",
AWS: &secretv0alpha1.AWSKeeperConfig{},
Azure: &secretv0alpha1.AzureKeeperConfig{},
GCP: &secretv0alpha1.GCPKeeperConfig{},
HashiCorp: &secretv0alpha1.HashiCorpKeeperConfig{},
},
}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
})
t.Run("at least one `keeper` must be present", func(t *testing.T) {
keeper := &secretv0alpha1.Keeper{
Spec: secretv0alpha1.KeeperSpec{
Description: "description",
},
}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
})
t.Run("aws keeper validation", func(t *testing.T) {
validKeeperAWS := &secretv0alpha1.Keeper{
Spec: secretv0alpha1.KeeperSpec{
Description: "description",
AWS: &secretv0alpha1.AWSKeeperConfig{
AWSCredentials: secretv0alpha1.AWSCredentials{
AccessKeyID: secretv0alpha1.CredentialValue{
ValueFromEnv: "some-value",
},
SecretAccessKey: secretv0alpha1.CredentialValue{
SecureValueName: "some-value",
},
KMSKeyID: "optional",
},
},
},
}
t.Run("`accessKeyId` must be present", func(t *testing.T) {
t.Run("at least one of the credential value must be present", func(t *testing.T) {
keeper := validKeeperAWS.DeepCopy()
keeper.Spec.AWS.AccessKeyID = secretv0alpha1.CredentialValue{}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.aws.accessKeyId", errs[0].Field)
})
t.Run("at most one of the credential value must be present", func(t *testing.T) {
keeper := validKeeperAWS.DeepCopy()
keeper.Spec.AWS.AccessKeyID = secretv0alpha1.CredentialValue{
SecureValueName: "a",
ValueFromEnv: "b",
ValueFromConfig: "c",
}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.aws.accessKeyId", errs[0].Field)
})
})
t.Run("`secretAccessKey` must be present", func(t *testing.T) {
t.Run("at least one of the credential value must be present", func(t *testing.T) {
keeper := validKeeperAWS.DeepCopy()
keeper.Spec.AWS.SecretAccessKey = secretv0alpha1.CredentialValue{}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.aws.secretAccessKey", errs[0].Field)
})
t.Run("at most one of the credential value must be present", func(t *testing.T) {
keeper := validKeeperAWS.DeepCopy()
keeper.Spec.AWS.SecretAccessKey = secretv0alpha1.CredentialValue{
SecureValueName: "a",
ValueFromEnv: "b",
ValueFromConfig: "c",
}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.aws.secretAccessKey", errs[0].Field)
})
})
})
t.Run("azure keeper validation", func(t *testing.T) {
validKeeperAzure := &secretv0alpha1.Keeper{
Spec: secretv0alpha1.KeeperSpec{
Description: "description",
Azure: &secretv0alpha1.AzureKeeperConfig{
AzureCredentials: secretv0alpha1.AzureCredentials{
KeyVaultName: "kv-name",
TenantID: "tenant-id",
ClientID: "client-id",
ClientSecret: secretv0alpha1.CredentialValue{
ValueFromConfig: "config.path.value",
},
},
},
},
}
t.Run("`keyVaultName` must be present", func(t *testing.T) {
keeper := validKeeperAzure.DeepCopy()
keeper.Spec.Azure.KeyVaultName = ""
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.azure.keyVaultName", errs[0].Field)
})
t.Run("`tenantId` must be present", func(t *testing.T) {
keeper := validKeeperAzure.DeepCopy()
keeper.Spec.Azure.TenantID = ""
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.azure.tenantId", errs[0].Field)
})
t.Run("`clientId` must be present", func(t *testing.T) {
keeper := validKeeperAzure.DeepCopy()
keeper.Spec.Azure.ClientID = ""
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.azure.clientId", errs[0].Field)
})
t.Run("`clientSecret` must be present", func(t *testing.T) {
t.Run("at least one of the credential value must be present", func(t *testing.T) {
keeper := validKeeperAzure.DeepCopy()
keeper.Spec.Azure.ClientSecret = secretv0alpha1.CredentialValue{}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.azure.clientSecret", errs[0].Field)
})
t.Run("at most one of the credential value must be present", func(t *testing.T) {
keeper := validKeeperAzure.DeepCopy()
keeper.Spec.Azure.ClientSecret = secretv0alpha1.CredentialValue{
SecureValueName: "a",
ValueFromEnv: "b",
ValueFromConfig: "c",
}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.azure.clientSecret", errs[0].Field)
})
})
})
t.Run("gcp keeper validation", func(t *testing.T) {
validKeeperGCP := &secretv0alpha1.Keeper{
Spec: secretv0alpha1.KeeperSpec{
Description: "description",
GCP: &secretv0alpha1.GCPKeeperConfig{
GCPCredentials: secretv0alpha1.GCPCredentials{
ProjectID: "project-id",
CredentialsFile: "/path/to/credentials/file.json",
},
},
},
}
t.Run("`projectId` must be present", func(t *testing.T) {
keeper := validKeeperGCP.DeepCopy()
keeper.Spec.GCP.ProjectID = ""
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.gcp.projectId", errs[0].Field)
})
t.Run("`credentialsFile` must be present", func(t *testing.T) {
keeper := validKeeperGCP.DeepCopy()
keeper.Spec.GCP.CredentialsFile = ""
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.gcp.credentialsFile", errs[0].Field)
})
})
t.Run("hashicorp keeper validation", func(t *testing.T) {
validKeeperHashiCorp := &secretv0alpha1.Keeper{
Spec: secretv0alpha1.KeeperSpec{
Description: "description",
HashiCorp: &secretv0alpha1.HashiCorpKeeperConfig{
HashiCorpCredentials: secretv0alpha1.HashiCorpCredentials{
Address: "http://address",
Token: secretv0alpha1.CredentialValue{
ValueFromConfig: "config.path.value",
},
},
},
},
}
t.Run("`address` must be present", func(t *testing.T) {
keeper := validKeeperHashiCorp.DeepCopy()
keeper.Spec.HashiCorp.Address = ""
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.hashicorp.address", errs[0].Field)
})
t.Run("`token` must be present", func(t *testing.T) {
t.Run("at least one of the credential value must be present", func(t *testing.T) {
keeper := validKeeperHashiCorp.DeepCopy()
keeper.Spec.HashiCorp.Token = secretv0alpha1.CredentialValue{}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.hashicorp.token", errs[0].Field)
})
t.Run("at most one of the credential value must be present", func(t *testing.T) {
keeper := validKeeperHashiCorp.DeepCopy()
keeper.Spec.HashiCorp.Token = secretv0alpha1.CredentialValue{
SecureValueName: "a",
ValueFromEnv: "b",
ValueFromConfig: "c",
}
errs := ValidateKeeper(keeper, admission.Create)
require.Len(t, errs, 1)
require.Equal(t, "spec.hashicorp.token", errs[0].Field)
})
})
})
}

@ -1,379 +0,0 @@
package reststorage
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
claims "github.com/grafana/authlib/types"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apimachinery/utils"
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"
)
var (
_ rest.Scoper = (*SecureValueRest)(nil)
_ rest.SingularNameProvider = (*SecureValueRest)(nil)
_ rest.Getter = (*SecureValueRest)(nil)
_ rest.Lister = (*SecureValueRest)(nil)
_ rest.Storage = (*SecureValueRest)(nil)
_ rest.Creater = (*SecureValueRest)(nil)
_ rest.Updater = (*SecureValueRest)(nil)
_ rest.GracefulDeleter = (*SecureValueRest)(nil)
)
// SecureValueRest is an implementation of CRUDL operations on a `securevalue` backed by a persistence layer `store`.
type SecureValueRest struct {
storage contracts.SecureValueMetadataStorage
resource utils.ResourceInfo
tableConverter rest.TableConvertor
}
// NewSecureValueRest is a returns a constructed `*SecureValueRest`.
func NewSecureValueRest(storage contracts.SecureValueMetadataStorage, resource utils.ResourceInfo) *SecureValueRest {
return &SecureValueRest{storage, resource, resource.TableConverter()}
}
// New returns an empty `*SecureValue` that is used by the `Create` method.
func (s *SecureValueRest) New() runtime.Object {
return s.resource.NewFunc()
}
// Destroy is called when? [TODO]
func (s *SecureValueRest) Destroy() {}
// NamespaceScoped returns `true` because the storage is namespaced (== org).
func (s *SecureValueRest) NamespaceScoped() bool {
return true
}
// GetSingularName is used by `kubectl` discovery to have singular name representation of resources.
func (s *SecureValueRest) GetSingularName() string {
return s.resource.GetSingularName()
}
// NewList returns an empty `*SecureValueList` that is used by the `List` method.
func (s *SecureValueRest) NewList() runtime.Object {
return s.resource.NewListFunc()
}
// ConvertToTable is used by Kubernetes and converts objects to `metav1.Table`.
func (s *SecureValueRest) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
// List calls the inner `store` (persistence) and returns a list of `securevalues` within a `namespace` filtered by the `options`.
func (s *SecureValueRest) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) {
namespace, ok := request.NamespaceFrom(ctx)
if !ok {
return nil, fmt.Errorf("missing namespace")
}
secureValueList, err := s.storage.List(ctx, xkube.Namespace(namespace))
if err != nil {
return nil, fmt.Errorf("failed to list secure values: %w", err)
}
labelSelector := options.LabelSelector
if labelSelector == nil {
labelSelector = labels.Everything()
}
fieldSelector := options.FieldSelector
if fieldSelector == nil {
fieldSelector = fields.Everything()
}
allowedSecureValues := make([]secretv0alpha1.SecureValue, 0, len(secureValueList))
for _, secureValue := range secureValueList {
// Filter by label
if labelSelector.Matches(labels.Set(secureValue.Labels)) {
// Filter by status.phase
if fieldSelector.Matches(fields.Set{"status.phase": string(secureValue.Status.Phase)}) {
allowedSecureValues = append(allowedSecureValues, secureValue)
}
}
}
return &secretv0alpha1.SecureValueList{Items: allowedSecureValues}, nil
}
// Get calls the inner `store` (persistence) and returns a `securevalue` by `name`. It will NOT return the decrypted `value`.
func (s *SecureValueRest) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
namespace, ok := request.NamespaceFrom(ctx)
if !ok {
return nil, fmt.Errorf("missing namespace")
}
sv, err := s.storage.Read(ctx, xkube.Namespace(namespace), name, contracts.ReadOpts{})
if err != nil {
if errors.Is(err, contracts.ErrSecureValueNotFound) {
return nil, s.resource.NewNotFound(name)
}
return nil, fmt.Errorf("failed to read secure value: %w", err)
}
return sv, nil
}
// Create a new `securevalue`. Does some validation and allows empty `name` (generated).
func (s *SecureValueRest) Create(
ctx context.Context,
obj runtime.Object,
createValidation rest.ValidateObjectFunc,
_ *metav1.CreateOptions,
) (runtime.Object, error) {
sv, ok := obj.(*secretv0alpha1.SecureValue)
if !ok {
return nil, fmt.Errorf("expected SecureValue for create")
}
if err := createValidation(ctx, obj); err != nil {
return nil, err
}
user, ok := claims.AuthInfoFrom(ctx)
if !ok {
return nil, fmt.Errorf("missing auth info in context")
}
createdSecureValueMetadata, err := s.storage.Create(ctx, sv, user.GetUID())
if err != nil {
return nil, fmt.Errorf("creating secure value %+w", err)
}
return createdSecureValueMetadata, nil
}
// Update a `securevalue`'s `value`. The second return parameter indicates whether the resource was newly created.
// Currently does not support "create on update" functionality. If the securevalue does not yet exist, it returns an error.
func (s *SecureValueRest) Update(
ctx context.Context,
name string,
objInfo rest.UpdatedObjectInfo,
_ rest.ValidateObjectFunc,
updateValidation rest.ValidateObjectUpdateFunc,
_forceAllowCreate bool,
_ *metav1.UpdateOptions,
) (runtime.Object, bool, error) {
oldObj, err := s.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, false, err
}
// Makes sure the UID and ResourceVersion are OK.
// TODO: this also makes it so the labels and annotations are additive, unless we check and remove manually.
newObj, err := objInfo.UpdatedObject(ctx, oldObj)
if err != nil {
return nil, false, fmt.Errorf("k8s updated object: %w", err)
}
if err := updateValidation(ctx, newObj, oldObj); err != nil {
return nil, false, err
}
newSecureValue, ok := newObj.(*secretv0alpha1.SecureValue)
if !ok {
return nil, false, fmt.Errorf("expected SecureValue for update")
}
// TODO: do we need to do this here again? Probably not, but double-check!
newSecureValue.Annotations = xkube.CleanAnnotations(newSecureValue.Annotations)
user, ok := claims.AuthInfoFrom(ctx)
if !ok {
return nil, false, fmt.Errorf("missing auth info in context")
}
// Current implementation replaces everything passed in the spec, so it is not a PATCH. Do we want/need to support that?
updatedSecureValueMetadata, err := s.storage.Update(ctx, newSecureValue, user.GetUID())
if err != nil {
return updatedSecureValueMetadata, false, fmt.Errorf("updating secure value metadata: %+w", err)
}
return updatedSecureValueMetadata, false, nil
}
// The second return parameter `bool` indicates whether the delete was instant or not. It always is for `securevalues`.
func (s *SecureValueRest) Delete(ctx context.Context, name string, _ rest.ValidateObjectFunc, _ *metav1.DeleteOptions) (runtime.Object, bool, error) {
namespace, ok := request.NamespaceFrom(ctx)
if !ok {
return nil, false, fmt.Errorf("missing namespace")
}
if err := s.storage.Delete(ctx, xkube.Namespace(namespace), name); err != nil {
if errors.Is(err, contracts.ErrSecureValueNotFound) {
return nil, false, s.resource.NewNotFound(name)
}
return nil, false, fmt.Errorf("deleting secure value: %+w", err)
}
return nil, false, nil
}
// ValidateSecureValue does basic spec validation of a securevalue.
func ValidateSecureValue(sv, oldSv *secretv0alpha1.SecureValue, operation admission.Operation, decryptersAllowList map[string]struct{}) field.ErrorList {
errs := make(field.ErrorList, 0)
// Operation-specific field validation.
switch operation {
case admission.Create:
errs = validateSecureValueCreate(sv)
// If we plan to support PATCH-style updates, we shouldn't be requiring fields to be set.
case admission.Update:
errs = validateSecureValueUpdate(sv, oldSv)
case admission.Delete:
case admission.Connect:
}
// General validations.
if len(sv.Spec.Value) > contracts.SECURE_VALUE_RAW_INPUT_MAX_SIZE_BYTES {
errs = append(
errs,
field.TooLong(field.NewPath("spec", "value"), len(sv.Spec.Value), contracts.SECURE_VALUE_RAW_INPUT_MAX_SIZE_BYTES),
)
}
if errs := validateDecrypters(sv.Spec.Decrypters, decryptersAllowList); len(errs) > 0 {
return errs
}
return errs
}
// validateSecureValueCreate does basic spec validation of a securevalue for the Create operation.
func validateSecureValueCreate(sv *secretv0alpha1.SecureValue) field.ErrorList {
errs := make(field.ErrorList, 0)
if sv.Spec.Description == "" {
errs = append(errs, field.Required(field.NewPath("spec", "description"), "a `description` is required"))
}
if sv.Spec.Value == "" && (sv.Spec.Ref == nil || (sv.Spec.Ref != nil && *sv.Spec.Ref == "")) {
errs = append(errs, field.Required(field.NewPath("spec"), "either a `value` or `ref` is required"))
}
if sv.Spec.Value != "" && (sv.Spec.Ref != nil && *sv.Spec.Ref != "") {
errs = append(errs, field.Forbidden(field.NewPath("spec"), "only one of `value` or `ref` can be set"))
}
return errs
}
// validateSecureValueUpdate does basic spec validation of a securevalue for the Update operation.
func validateSecureValueUpdate(sv, oldSv *secretv0alpha1.SecureValue) field.ErrorList {
errs := make(field.ErrorList, 0)
// For updates, an `old` object is required.
if oldSv == nil {
errs = append(errs, field.InternalError(field.NewPath("spec"), errors.New("old object is nil")))
return errs
}
// Only validate if one of the fields is being changed/set.
if sv.Spec.Value != "" || (sv.Spec.Ref != nil && *sv.Spec.Ref != "") {
if (oldSv.Spec.Ref != nil && *oldSv.Spec.Ref != "") && sv.Spec.Value != "" {
errs = append(errs, field.Forbidden(field.NewPath("spec"), "cannot set `value` when `ref` was already previously set"))
}
if (oldSv.Spec.Ref == nil || (oldSv.Spec.Ref != nil && *oldSv.Spec.Ref == "")) && (sv.Spec.Ref != nil && *sv.Spec.Ref != "") {
errs = append(errs, field.Forbidden(field.NewPath("spec"), "cannot set `ref` when `value` was already previously set"))
}
}
// Keeper cannot be changed.
if sv.Spec.Keeper != oldSv.Spec.Keeper {
errs = append(errs, field.Forbidden(field.NewPath("spec"), "the `keeper` cannot be changed"))
}
return errs
}
// validateDecrypters validates that (if populated) the `decrypters` must be unique.
func validateDecrypters(decrypters []string, decryptersAllowList map[string]struct{}) field.ErrorList {
errs := make(field.ErrorList, 0)
// Limit the number of decrypters to 64 to not have it unbounded.
// The number was chosen arbitrarily and should be enough.
if len(decrypters) > 64 {
errs = append(
errs,
field.TooMany(field.NewPath("spec", "decrypters"), len(decrypters), 64),
)
return errs
}
decrypterNames := make(map[string]struct{}, 0)
for i, decrypter := range decrypters {
decrypter = strings.TrimSpace(decrypter)
if decrypter == "" {
errs = append(
errs,
field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, "decrypters cannot be empty if specified"),
)
continue
}
// Allow List: decrypters must match exactly and be in the allowed list to be able to decrypt.
if len(decryptersAllowList) > 0 {
if _, exists := decryptersAllowList[decrypter]; !exists {
errs = append(
errs,
field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, fmt.Sprintf("allowed values: %v", decryptersAllowList)),
)
return errs
}
continue
}
// Use the same validation as labels for the decrypters.
if verrs := validation.IsValidLabelValue(decrypter); len(verrs) > 0 {
for _, verr := range verrs {
errs = append(
errs,
field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, verr),
)
}
continue
}
if _, exists := decrypterNames[decrypter]; exists {
errs = append(
errs,
field.Invalid(field.NewPath("spec", "decrypters", "["+strconv.Itoa(i)+"]"), decrypter, "decrypters must be unique"),
)
continue
}
decrypterNames[decrypter] = struct{}{}
}
return errs
}

@ -1,308 +0,0 @@
package reststorage
import (
"fmt"
"maps"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/admission"
secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
)
func TestValidateSecureValue(t *testing.T) {
t.Run("when creating a new securevalue", func(t *testing.T) {
keeper := "keeper"
validSecureValue := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description",
Value: "value",
Keeper: &keeper,
Decrypters: []string{"app1", "app2"},
},
}
t.Run("the `description` must be present", func(t *testing.T) {
sv := validSecureValue.DeepCopy()
sv.Spec.Description = ""
errs := ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec.description", errs[0].Field)
})
t.Run("either a `value` or `ref` must be present but not both", func(t *testing.T) {
sv := validSecureValue.DeepCopy()
sv.Spec.Value = ""
sv.Spec.Ref = nil
errs := ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
ref := "value"
sv.Spec.Value = "value"
sv.Spec.Ref = &ref
errs = ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
})
t.Run("`value` cannot exceed 24576 bytes", func(t *testing.T) {
sv := validSecureValue.DeepCopy()
sv.Spec.Value = secretv0alpha1.NewExposedSecureValue(strings.Repeat("a", contracts.SECURE_VALUE_RAW_INPUT_MAX_SIZE_BYTES+1))
sv.Spec.Ref = nil
errs := ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec.value", errs[0].Field)
})
})
t.Run("when updating a securevalue", func(t *testing.T) {
t.Run("when trying to switch from a `value` (old) to a `ref` (new), it returns an error", func(t *testing.T) {
oldSv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Ref: nil, // empty `ref` means a `value` was present.
},
}
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Ref: &ref,
},
}
errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
})
t.Run("when trying to switch from a `ref` (old) to a `value` (new), it returns an error", func(t *testing.T) {
ref := "non-empty"
oldSv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Ref: &ref,
},
}
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Value: "value",
},
}
errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
})
t.Run("when both `value` and `ref` are set, it returns an error", func(t *testing.T) {
refNonEmpty := "non-empty"
oldSv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Ref: &refNonEmpty,
},
}
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Value: "value",
Ref: &ref,
},
}
errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
oldSv = &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Value: "non-empty",
},
}
errs = ValidateSecureValue(sv, oldSv, admission.Update, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
})
t.Run("when no changes are made, it returns no errors", func(t *testing.T) {
oldSv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "old-description",
},
}
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "new-description",
},
}
errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
require.Empty(t, errs)
})
t.Run("when the old object is `nil` it returns an error", func(t *testing.T) {
sv := &secretv0alpha1.SecureValue{}
errs := ValidateSecureValue(sv, nil, admission.Update, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
})
t.Run("when trying to change the `keeper`, it returns an error", func(t *testing.T) {
keeperA := "a-keeper"
keeperAnother := "another-keeper"
oldSv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Keeper: &keeperA,
},
}
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Keeper: &keeperAnother,
},
}
errs := ValidateSecureValue(sv, oldSv, admission.Update, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec", errs[0].Field)
})
})
t.Run("`decrypters` must have unique items", func(t *testing.T) {
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: []string{
"app1",
"app1",
},
},
}
errs := ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec.decrypters.[1]", errs[0].Field)
})
t.Run("when set, the `decrypters` must be one of the allowed in the allow list", func(t *testing.T) {
allowList := map[string]struct{}{"app1": {}, "app2": {}}
decrypters := slices.Collect(maps.Keys(allowList))
t.Run("no matches, returns an error", func(t *testing.T) {
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: []string{"app3"},
},
}
errs := ValidateSecureValue(sv, nil, admission.Create, allowList)
require.Len(t, errs, 1)
})
t.Run("no decrypters, returns no error", func(t *testing.T) {
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: []string{},
},
}
errs := ValidateSecureValue(sv, nil, admission.Create, allowList)
require.Empty(t, errs)
})
t.Run("one match, returns no errors", func(t *testing.T) {
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: []string{decrypters[0]},
},
}
errs := ValidateSecureValue(sv, nil, admission.Create, allowList)
require.Empty(t, errs)
})
t.Run("all matches, returns no errors", func(t *testing.T) {
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: decrypters,
},
}
errs := ValidateSecureValue(sv, nil, admission.Create, allowList)
require.Empty(t, errs)
})
})
t.Run("`decrypters` must be a valid label value", func(t *testing.T) {
decrypters := []string{
"", // invalid
"is/this/valid", // invalid
"is this valid", // invalid
"is.this.valid",
"is-this-valid",
"is_this_valid",
"0isthisvalid9",
"isthisvalid9",
"0isthisvalid",
"isthisvalid",
}
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: decrypters,
},
}
errs := ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, 3)
})
t.Run("`decrypters` cannot have more than 64 items", func(t *testing.T) {
decrypters := make([]string, 0, 64+1)
for i := 0; i < 64+1; i++ {
decrypters = append(decrypters, fmt.Sprintf("app%d", i))
}
ref := "ref"
sv := &secretv0alpha1.SecureValue{
Spec: secretv0alpha1.SecureValueSpec{
Description: "description", Ref: &ref,
Decrypters: decrypters,
},
}
errs := ValidateSecureValue(sv, nil, admission.Create, nil)
require.Len(t, errs, 1)
require.Equal(t, "spec.decrypters", errs[0].Field)
})
}

@ -46,6 +46,9 @@ var WireSet = wire.NewSet(
wire.Bind(new(datasource.PluginContextWrapper), new(*plugincontext.Provider)),
datasource.ProvideDefaultPluginConfigs,
// Secrets
secret.RegisterDependencies,
// Each must be added here *and* in the ServiceSink above
dashboardinternal.RegisterAPIService,
dashboardsnapshot.RegisterAPIService,
@ -58,7 +61,6 @@ var WireSet = wire.NewSet(
provisioning.RegisterAPIService,
service.RegisterAPIService,
query.RegisterAPIService,
secret.RegisterAPIService,
userstorage.RegisterAPIService,
ofrep.RegisterAPIService,
)

@ -732,20 +732,6 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser
return nil, err
}
userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer)
databaseDatabase := database5.ProvideDatabase(sqlStore)
secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(databaseDatabase, featureToggles)
if err != nil {
return nil, err
}
keeperMetadataStorage, err := metadata.ProvideKeeperMetadataStorage(databaseDatabase, featureToggles)
if err != nil {
return nil, err
}
secretDBMigrator := migrator2.NewWithEngine(sqlStore)
secretAPIBuilder, err := secret.RegisterAPIService(featureToggles, cfg, apiserverService, tracingService, secureValueMetadataStorage, keeperMetadataStorage, accessClient, acimplService, secretDBMigrator)
if err != nil {
return nil, err
}
factory := github.ProvideFactory()
legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, accessControl)
webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, featureToggles, secretsService, factory, renderingService, resourceClient, eventualRestConfigProvider)
@ -759,7 +745,12 @@ func Initialize(cfg *setting.Cfg, opts Options, apiOpts api.ServerOptions) (*Ser
return nil, err
}
ofrepAPIBuilder := ofrep.RegisterAPIService(apiserverService, cfg, staticFlagEvaluator)
apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, secretAPIBuilder, apiBuilder, ofrepAPIBuilder)
secretDBMigrator := migrator2.NewWithEngine(sqlStore)
dependencyRegisterer, err := secret.RegisterDependencies(featureToggles, cfg, secretDBMigrator, acimplService)
if err != nil {
return nil, err
}
apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, ofrepAPIBuilder, dependencyRegisterer)
teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService)
if err != nil {
return nil, err
@ -1254,20 +1245,6 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface {
return nil, err
}
userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer)
databaseDatabase := database5.ProvideDatabase(sqlStore)
secureValueMetadataStorage, err := metadata.ProvideSecureValueMetadataStorage(databaseDatabase, featureToggles)
if err != nil {
return nil, err
}
keeperMetadataStorage, err := metadata.ProvideKeeperMetadataStorage(databaseDatabase, featureToggles)
if err != nil {
return nil, err
}
secretDBMigrator := migrator2.NewWithEngine(sqlStore)
secretAPIBuilder, err := secret.RegisterAPIService(featureToggles, cfg, apiserverService, tracingService, secureValueMetadataStorage, keeperMetadataStorage, accessClient, acimplService, secretDBMigrator)
if err != nil {
return nil, err
}
factory := github.ProvideFactory()
legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, accessControl)
webhookExtraBuilder := webhooks.ProvideWebhooks(cfg, featureToggles, secretsService, factory, renderingService, resourceClient, eventualRestConfigProvider)
@ -1281,7 +1258,12 @@ func InitializeForTest(t sqlutil.ITestDB, testingT interface {
return nil, err
}
ofrepAPIBuilder := ofrep.RegisterAPIService(apiserverService, cfg, staticFlagEvaluator)
apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, secretAPIBuilder, apiBuilder, ofrepAPIBuilder)
secretDBMigrator := migrator2.NewWithEngine(sqlStore)
dependencyRegisterer, err := secret.RegisterDependencies(featureToggles, cfg, secretDBMigrator, acimplService)
if err != nil {
return nil, err
}
apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, featureFlagAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, ofrepAPIBuilder, dependencyRegisterer)
teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService)
if err != nil {
return nil, err

@ -1,567 +0,0 @@
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) {
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) {
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)
})
})
}

@ -1,150 +0,0 @@
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
}

@ -1,15 +0,0 @@
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

@ -1,14 +0,0 @@
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

@ -1,15 +0,0 @@
apiVersion: secret.grafana.app/v0alpha1
kind: SecureValue
metadata:
annotations:
xx: XXX
yy: YYY
labels:
aa: AAA
bb: BBB
spec:
description: This is a secret
value: this is super duper secure
decrypters:
- k6
- synthetic-monitoring

@ -1,16 +0,0 @@
apiVersion: secret.grafana.app/v0alpha1
kind: SecureValue
metadata:
annotations:
xx: XXX
yy: YYY
labels:
aa: AAA
bb: BBB
spec:
description: XYZ value
keeper: my-keeper-1
value: super duper secure
decrypters:
- k6
- synthetic-monitoring
Loading…
Cancel
Save