diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a432ee06073..f0cb1652a5c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -126,6 +126,7 @@ /pkg/apimachinery/errutil/ @grafana/grafana-backend-group /pkg/promlib @grafana/oss-big-tent /pkg/storage/ @grafana/grafana-search-and-storage +/pkg/storage/secret/ @grafana/grafana-operator-experience-squad /pkg/services/annotations/ @grafana/grafana-search-and-storage /pkg/services/apikey/ @grafana/identity-squad /pkg/services/cleanup/ @grafana/grafana-backend-group @@ -736,6 +737,7 @@ embed.go @grafana/grafana-as-code /pkg/registry/apis/ @grafana/grafana-app-platform-squad /pkg/registry/apis/alerting @grafana/grafana-app-platform-squad @grafana/alerting-backend /pkg/registry/apis/query @grafana/grafana-datasources-core-services +/pkg/registry/apis/secret @grafana/grafana-operator-experience-squad /pkg/registry/apis/userstorage @grafana/grafana-app-platform-squad @grafana/plugins-platform-backend /pkg/registry/apps/advisor @grafana/plugins-platform-backend /pkg/codegen/ @grafana/grafana-as-code diff --git a/apps/alerting/notifications/go.mod b/apps/alerting/notifications/go.mod index 8ba8c1f78e1..6f0c3f390d0 100644 --- a/apps/alerting/notifications/go.mod +++ b/apps/alerting/notifications/go.mod @@ -36,7 +36,7 @@ require ( github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432 // indirect + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250314071911-14e2784e6979 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/apps/alerting/notifications/go.sum b/apps/alerting/notifications/go.sum index a42cf3aacb2..8df3afad699 100644 --- a/apps/alerting/notifications/go.sum +++ b/apps/alerting/notifications/go.sum @@ -69,8 +69,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafana/grafana-app-sdk v0.31.0 h1:/mFCcx+YqG8cWAi9hePDJQxIdtXDClDIDRgZwHkksFk= github.com/grafana/grafana-app-sdk v0.31.0/go.mod h1:Xw00NL7qpRLo5r3Gn48Bl1Xn2n4eUDI5pYf/wMufKWs= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432 h1:/0MLOGx9Ow7ihR4smlUYHFvomXBpdpf/jLWHKNfEUiI= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432/go.mod h1:A/SJ9CiAWNOdeD/IezNwRaDZusLKq0z6dTfhKDgZw5Y= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250314071911-14e2784e6979 h1:B7kt2We4CVCWRJGgaIyx8lhZqTeDAk6bspOkGYWoB/I= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250314071911-14e2784e6979/go.mod h1:A/SJ9CiAWNOdeD/IezNwRaDZusLKq0z6dTfhKDgZw5Y= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= @@ -109,10 +109,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= -github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= -github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= -github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/go.mod b/go.mod index 10be8a8a6da..7bc11ba770b 100644 --- a/go.mod +++ b/go.mod @@ -208,7 +208,8 @@ require ( github.com/grafana/grafana/apps/investigations v0.0.0-20250220163425-b4c4b9abbdc8 // @fcjack @matryer github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 // @grafana/grafana-app-platform-squad - github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250314071911-14e2784e6979 // @grafana/grafana-app-platform-squad + github.com/grafana/grafana/pkg/apis/secret v0.0.0-20250319110241-5a004939da2a // @grafana/grafana-operator-experience-squad github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 // @grafana/grafana-app-platform-squad // This needs to be here for other projects that import grafana/grafana @@ -562,8 +563,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.49.0 // indirect github.com/bluele/gcache v0.0.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.3 // indirect - github.com/onsi/ginkgo/v2 v2.22.0 // indirect - github.com/onsi/gomega v1.36.1 // indirect github.com/open-feature/go-sdk-contrib/providers/ofrep v0.1.5 // indirect ) diff --git a/go.sum b/go.sum index 4255f120999..fafd5bcdac2 100644 --- a/go.sum +++ b/go.sum @@ -1588,8 +1588,10 @@ github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450 h1:h github.com/grafana/grafana/apps/playlist v0.0.0-20250220164708-c8d4ff28a450/go.mod h1:KKIsWpbv88Lwwcvdjon73zFL7vNJvuXLtsSoUjJErTw= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8 h1:9qOLpC21AmXZqZ6rUhrBWl2mVqS3CzV53pzw0BCuHt0= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250220163425-b4c4b9abbdc8/go.mod h1:deLQ/ywLvpVGbncRGUA4UDGt8a5Ei9sivOP+x6AQ2ko= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432 h1:/0MLOGx9Ow7ihR4smlUYHFvomXBpdpf/jLWHKNfEUiI= -github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432/go.mod h1:A/SJ9CiAWNOdeD/IezNwRaDZusLKq0z6dTfhKDgZw5Y= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250314071911-14e2784e6979 h1:B7kt2We4CVCWRJGgaIyx8lhZqTeDAk6bspOkGYWoB/I= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250314071911-14e2784e6979/go.mod h1:A/SJ9CiAWNOdeD/IezNwRaDZusLKq0z6dTfhKDgZw5Y= +github.com/grafana/grafana/pkg/apis/secret v0.0.0-20250319110241-5a004939da2a h1:dMllTcE0R1qvV4rWDehQzxNiHaale1yCzXsVkub07D0= +github.com/grafana/grafana/pkg/apis/secret v0.0.0-20250319110241-5a004939da2a/go.mod h1:K/fP4kODJmABug5b90PhACUZD6Xh/veEz2b1VRKNyuA= github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 h1:ivbywO8ZnmzDDkn169qUb9REsCGYAA7H8W6VfqSmCEw= github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295/go.mod h1:OugmKouuvgWeOI8Kghram2Pv8b/1SuXRR8/iW068uLk= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go index aebcfcbbdd0..0ba63af8484 100644 --- a/pkg/registry/apis/apis.go +++ b/pkg/registry/apis/apis.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/iam" "github.com/grafana/grafana/pkg/registry/apis/provisioning" "github.com/grafana/grafana/pkg/registry/apis/query" + "github.com/grafana/grafana/pkg/registry/apis/secret" "github.com/grafana/grafana/pkg/registry/apis/userstorage" ) @@ -27,6 +28,7 @@ func ProvideRegistryServiceSink( _ *query.QueryAPIBuilder, _ *notifications.NotificationsAPIBuilder, _ *userstorage.UserStorageAPIBuilder, + _ *secret.SecretAPIBuilder, _ *provisioning.APIBuilder, ) *Service { return &Service{} diff --git a/pkg/registry/apis/secret/contracts/keeper.go b/pkg/registry/apis/secret/contracts/keeper.go new file mode 100644 index 00000000000..0f9ab5e62bb --- /dev/null +++ b/pkg/registry/apis/secret/contracts/keeper.go @@ -0,0 +1,17 @@ +package contracts + +import ( + "context" + + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "k8s.io/apimachinery/pkg/apis/meta/internalversion" +) + +type KeeperMetadataStorage interface { + Create(ctx context.Context, keeper *secretv0alpha1.Keeper) (*secretv0alpha1.Keeper, error) + Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv0alpha1.Keeper, error) + Update(ctx context.Context, keeper *secretv0alpha1.Keeper) (*secretv0alpha1.Keeper, error) + Delete(ctx context.Context, namespace xkube.Namespace, name string) error + List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.KeeperList, error) +} diff --git a/pkg/registry/apis/secret/contracts/secure_value.go b/pkg/registry/apis/secret/contracts/secure_value.go new file mode 100644 index 00000000000..5a848731872 --- /dev/null +++ b/pkg/registry/apis/secret/contracts/secure_value.go @@ -0,0 +1,17 @@ +package contracts + +import ( + "context" + + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/secret/xkube" + "k8s.io/apimachinery/pkg/apis/meta/internalversion" +) + +type SecureValueMetadataStorage interface { + Create(ctx context.Context, sv *secretv0alpha1.SecureValue) (*secretv0alpha1.SecureValue, error) + Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv0alpha1.SecureValue, error) + Update(ctx context.Context, sv *secretv0alpha1.SecureValue) (*secretv0alpha1.SecureValue, error) + Delete(ctx context.Context, namespace xkube.Namespace, name string) error + List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.SecureValueList, error) +} diff --git a/pkg/registry/apis/secret/register.go b/pkg/registry/apis/secret/register.go new file mode 100644 index 00000000000..457df144c48 --- /dev/null +++ b/pkg/registry/apis/secret/register.go @@ -0,0 +1,260 @@ +package secret + +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" + "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) + _ builder.APIGroupRouteProvider = (*SecretAPIBuilder)(nil) +) + +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( + features featuremgmt.FeatureToggles, + cfg *setting.Cfg, + apiregistration builder.APIRegistrar, + tracer tracing.Tracer, + secureValueMetadataStorage contracts.SecureValueMetadataStorage, + keeperMetadataStorage contracts.KeeperMetadataStorage, + accessClient claims.AccessClient, + accessControlService accesscontrol.Service, +) (*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) { + return nil, nil + } + + 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 ` 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 +} + +// 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, 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 nil +} + +// Register additional routes with the server. +func (b *SecretAPIBuilder) GetAPIRoutes() *builder.APIRoutes { + return nil +} + +// 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 + } + + 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 + } + } + + // 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 = "" + } + } + + return nil +} diff --git a/pkg/registry/apis/secret/reststorage/keeper.go b/pkg/registry/apis/secret/reststorage/keeper.go new file mode 100644 index 00000000000..7803d28d1ad --- /dev/null +++ b/pkg/registry/apis/secret/reststorage/keeper.go @@ -0,0 +1,128 @@ +package reststorage + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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" +) + +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 ddimplementation of CRUDL operations on a `keeper` backed by TODO. +type KeeperRest struct { + storage contracts.KeeperMetadataStorage + resource utils.ResourceInfo + tableConverter rest.TableConvertor +} + +// NewKeeperRest is a returns a constructed `*KeeperRest`. +func NewKeeperRest(storage contracts.KeeperMetadataStorage, resource utils.ResourceInfo) *KeeperRest { + return &KeeperRest{storage, 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) { + _, ok := request.NamespaceFrom(ctx) + if !ok { + return nil, fmt.Errorf("missing namespace") + } + + return &secretv0alpha1.KeeperList{Items: make([]secretv0alpha1.Keeper, 0)}, 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) { + _, ok := request.NamespaceFrom(ctx) + if !ok { + return nil, fmt.Errorf("missing namespace") + } + + return nil, s.resource.NewNotFound(name) +} + +// 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) { + return nil, 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) { + return nil, 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) { + _, ok := request.NamespaceFrom(ctx) + if !ok { + return nil, false, fmt.Errorf("missing namespace") + } + + return nil, true, nil +} + +// ValidateKeeper does basic spec validation of a keeper. +func ValidateKeeper(keeper *secretv0alpha1.Keeper, operation admission.Operation) field.ErrorList { + return nil +} diff --git a/pkg/registry/apis/secret/reststorage/secure_value.go b/pkg/registry/apis/secret/reststorage/secure_value.go new file mode 100644 index 00000000000..10dc2695170 --- /dev/null +++ b/pkg/registry/apis/secret/reststorage/secure_value.go @@ -0,0 +1,129 @@ +package reststorage + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/apis/meta/internalversion" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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" +) + +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) { + _, ok := request.NamespaceFrom(ctx) + if !ok { + return nil, fmt.Errorf("missing namespace") + } + + return &secretv0alpha1.SecureValueList{Items: make([]secretv0alpha1.SecureValue, 0)}, 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) { + _, ok := request.NamespaceFrom(ctx) + if !ok { + return nil, fmt.Errorf("missing namespace") + } + + return nil, s.resource.NewNotFound(name) +} + +// 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, + options *metav1.CreateOptions, +) (runtime.Object, error) { + return nil, 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, + createValidation rest.ValidateObjectFunc, + updateValidation rest.ValidateObjectUpdateFunc, + forceAllowCreate bool, + options *metav1.UpdateOptions, +) (runtime.Object, bool, error) { + return nil, false, nil +} + +// Delete calls the inner `store` (persistence) in order to delete the `securevalue`. +// 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, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { + _, ok := request.NamespaceFrom(ctx) + if !ok { + return nil, false, fmt.Errorf("missing namespace") + } + + return nil, true, nil +} + +// ValidateSecureValue does basic spec validation of a securevalue. +func ValidateSecureValue(sv, oldSv *secretv0alpha1.SecureValue, operation admission.Operation, decryptersAllowList map[string]struct{}) field.ErrorList { + return nil +} diff --git a/pkg/registry/apis/secret/xkube/namespace.go b/pkg/registry/apis/secret/xkube/namespace.go new file mode 100644 index 00000000000..72fb3f3afd2 --- /dev/null +++ b/pkg/registry/apis/secret/xkube/namespace.go @@ -0,0 +1,8 @@ +package xkube + +// Namespace is a newtype of string that improves type safety. +type Namespace string + +func (n Namespace) String() string { + return string(n) +} diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index ecd0cba0afb..3ff07823e1a 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/iam" "github.com/grafana/grafana/pkg/registry/apis/provisioning" "github.com/grafana/grafana/pkg/registry/apis/query" + "github.com/grafana/grafana/pkg/registry/apis/secret" "github.com/grafana/grafana/pkg/registry/apis/service" "github.com/grafana/grafana/pkg/registry/apis/userstorage" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" @@ -36,5 +37,6 @@ var WireSet = wire.NewSet( service.RegisterAPIService, query.RegisterAPIService, notifications.RegisterAPIService, + secret.RegisterAPIService, userstorage.RegisterAPIService, ) diff --git a/pkg/server/wire.go b/pkg/server/wire.go index e211b739ecb..a3cefc7fb77 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -159,6 +159,7 @@ import ( "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/setting" legacydualwrite "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + secretmetadata "github.com/grafana/grafana/pkg/storage/secret/metadata" "github.com/grafana/grafana/pkg/storage/unified/resource" unifiedsearch "github.com/grafana/grafana/pkg/storage/unified/search" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" @@ -402,6 +403,9 @@ var wireBasicSet = wire.NewSet( connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, + // Secrets Manager + secretmetadata.ProvideSecureValueMetadataStorage, + secretmetadata.ProvideKeeperMetadataStorage, // Unified storage resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, diff --git a/pkg/storage/secret/metadata/keeper_store.go b/pkg/storage/secret/metadata/keeper_store.go new file mode 100644 index 00000000000..ebc58801fd8 --- /dev/null +++ b/pkg/storage/secret/metadata/keeper_store.go @@ -0,0 +1,48 @@ +package metadata + +import ( + "context" + + claims "github.com/grafana/authlib/types" + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/infra/db" + "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" + "k8s.io/apimachinery/pkg/apis/meta/internalversion" +) + +func ProvideKeeperMetadataStorage(db db.DB, features featuremgmt.FeatureToggles, accessClient claims.AccessClient) (contracts.KeeperMetadataStorage, error) { + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) || + !features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) { + return &keeperMetadataStorage{}, nil + } + + return &keeperMetadataStorage{db: db, accessClient: accessClient}, nil +} + +// keeperMetadataStorage is the actual implementation of the keeper metadata storage. +type keeperMetadataStorage struct { + db db.DB + accessClient claims.AccessClient +} + +func (s *keeperMetadataStorage) Create(ctx context.Context, keeper *secretv0alpha1.Keeper) (*secretv0alpha1.Keeper, error) { + return nil, nil +} + +func (s *keeperMetadataStorage) Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv0alpha1.Keeper, error) { + return nil, nil +} + +func (s *keeperMetadataStorage) Update(ctx context.Context, newKeeper *secretv0alpha1.Keeper) (*secretv0alpha1.Keeper, error) { + return nil, nil +} + +func (s *keeperMetadataStorage) Delete(ctx context.Context, namespace xkube.Namespace, name string) error { + return nil +} + +func (s *keeperMetadataStorage) List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.KeeperList, error) { + return nil, nil +} diff --git a/pkg/storage/secret/metadata/secure_value_store.go b/pkg/storage/secret/metadata/secure_value_store.go new file mode 100644 index 00000000000..1c55a430548 --- /dev/null +++ b/pkg/storage/secret/metadata/secure_value_store.go @@ -0,0 +1,49 @@ +package metadata + +import ( + "context" + + claims "github.com/grafana/authlib/types" + + secretv0alpha1 "github.com/grafana/grafana/pkg/apis/secret/v0alpha1" + "github.com/grafana/grafana/pkg/infra/db" + "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" + "k8s.io/apimachinery/pkg/apis/meta/internalversion" +) + +func ProvideSecureValueMetadataStorage(db db.DB, features featuremgmt.FeatureToggles, accessClient claims.AccessClient) (contracts.SecureValueMetadataStorage, error) { + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) || + !features.IsEnabledGlobally(featuremgmt.FlagSecretsManagementAppPlatform) { + return &secureValueMetadataStorage{}, nil + } + + return &secureValueMetadataStorage{db: db, accessClient: accessClient}, nil +} + +// secureValueMetadataStorage is the actual implementation of the secure value metadata storage. +type secureValueMetadataStorage struct { + db db.DB + accessClient claims.AccessClient +} + +func (s *secureValueMetadataStorage) Create(ctx context.Context, sv *secretv0alpha1.SecureValue) (*secretv0alpha1.SecureValue, error) { + return nil, nil +} + +func (s *secureValueMetadataStorage) Read(ctx context.Context, namespace xkube.Namespace, name string) (*secretv0alpha1.SecureValue, error) { + return nil, nil +} + +func (s *secureValueMetadataStorage) Update(ctx context.Context, newSecureValue *secretv0alpha1.SecureValue) (*secretv0alpha1.SecureValue, error) { + return nil, nil +} + +func (s *secureValueMetadataStorage) Delete(ctx context.Context, namespace xkube.Namespace, name string) error { + return nil +} + +func (s *secureValueMetadataStorage) List(ctx context.Context, namespace xkube.Namespace, options *internalversion.ListOptions) (*secretv0alpha1.SecureValueList, error) { + return nil, nil +} diff --git a/pkg/storage/unified/apistore/go.mod b/pkg/storage/unified/apistore/go.mod index 0bf35029b66..27ec63b47c5 100644 --- a/pkg/storage/unified/apistore/go.mod +++ b/pkg/storage/unified/apistore/go.mod @@ -17,7 +17,7 @@ require ( github.com/grafana/authlib/types v0.0.0-20250224151205-5ef97131cc82 github.com/grafana/grafana v11.4.0-00010101000000-000000000000+incompatible github.com/grafana/grafana/apps/dashboard v0.0.0-20250317130411-3f270d1de043 - github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432 + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250314071911-14e2784e6979 github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 github.com/grafana/grafana/pkg/storage/unified/resource v0.0.0-20250317130411-3f270d1de043 github.com/stretchr/testify v1.10.0 diff --git a/pkg/storage/unified/resource/go.mod b/pkg/storage/unified/resource/go.mod index 3454e9471c8..bd4cea71221 100644 --- a/pkg/storage/unified/resource/go.mod +++ b/pkg/storage/unified/resource/go.mod @@ -17,7 +17,7 @@ require ( github.com/grafana/grafana v11.4.0-00010101000000-000000000000+incompatible github.com/grafana/grafana-plugin-sdk-go v0.272.0 github.com/grafana/grafana/apps/dashboard v0.0.0-20250317130411-3f270d1de043 - github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250312121619-f64be062c432 + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250314071911-14e2784e6979 github.com/grafana/grafana/pkg/apiserver v0.0.0-20250220154326-6e5de80ef295 // indirect github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1