Provisioning: Add inline secure values to repository schema (#109594)

This commit is contained in:
Ryan McKinley
2025-08-20 09:05:41 +00:00
committed by GitHub
parent c37a03263f
commit fa81fae1e3
13 changed files with 333 additions and 28 deletions
@@ -17,9 +17,23 @@ type Repository struct {
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec RepositorySpec `json:"spec,omitempty"`
Secure SecureValues `json:"secure,omitzero,omitempty"`
Status RepositoryStatus `json:"status,omitempty"`
}
// NOT YET USED FOR REAL -- testing secure value workflow
type SecureValues struct {
// Token used to connect the configured repository
Token common.InlineSecureValue `json:"token,omitzero,omitempty"`
// Some webhooks (github) require a secret key value
WebhookSecret common.InlineSecureValue `json:"webhookSecret,omitzero,omitempty"`
}
func (v SecureValues) IsZero() bool {
return v.Token.IsZero() && v.WebhookSecret.IsZero()
}
type LocalRepositoryConfig struct {
Path string `json:"path,omitempty"`
}
@@ -665,6 +665,7 @@ func (in *Repository) DeepCopyInto(out *Repository) {
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
out.Secure = in.Secure
in.Status.DeepCopyInto(&out.Status)
return
}
@@ -1085,6 +1086,24 @@ func (in *ResourceWrapper) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SecureValues) DeepCopyInto(out *SecureValues) {
*out = *in
out.Token = in.Token
out.WebhookSecret = in.WebhookSecret
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecureValues.
func (in *SecureValues) DeepCopy() *SecureValues {
if in == nil {
return nil
}
out := new(SecureValues)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SyncJobOptions) DeepCopyInto(out *SyncJobOptions) {
*out = *in
@@ -57,6 +57,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceStats": schema_pkg_apis_provisioning_v0alpha1_ResourceStats(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceType": schema_pkg_apis_provisioning_v0alpha1_ResourceType(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.ResourceWrapper": schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SecureValues": schema_pkg_apis_provisioning_v0alpha1_SecureValues(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SyncJobOptions": schema_pkg_apis_provisioning_v0alpha1_SyncJobOptions(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SyncOptions": schema_pkg_apis_provisioning_v0alpha1_SyncOptions(ref),
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SyncStatus": schema_pkg_apis_provisioning_v0alpha1_SyncStatus(ref),
@@ -1435,6 +1436,12 @@ func schema_pkg_apis_provisioning_v0alpha1_Repository(ref common.ReferenceCallba
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositorySpec"),
},
},
"secure": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SecureValues"),
},
},
"status": {
SchemaProps: spec.SchemaProps{
Default: map[string]interface{}{},
@@ -1445,7 +1452,7 @@ func schema_pkg_apis_provisioning_v0alpha1_Repository(ref common.ReferenceCallba
},
},
Dependencies: []string{
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositorySpec", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryStatus", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
"github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositorySpec", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.RepositoryStatus", "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1.SecureValues", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"},
}
}
@@ -2359,6 +2366,35 @@ func schema_pkg_apis_provisioning_v0alpha1_ResourceWrapper(ref common.ReferenceC
}
}
func schema_pkg_apis_provisioning_v0alpha1_SecureValues(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Description: "NOT YET USED FOR REAL -- testing secure value workflow",
Type: []string{"object"},
Properties: map[string]spec.Schema{
"token": {
SchemaProps: spec.SchemaProps{
Description: "Token used to connect the configured repository",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"),
},
},
"webhookSecret": {
SchemaProps: spec.SchemaProps{
Description: "Some webhooks (github) require a secret key value",
Default: map[string]interface{}{},
Ref: ref("github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"),
},
},
},
},
},
Dependencies: []string{
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1.InlineSecureValue"},
}
}
func schema_pkg_apis_provisioning_v0alpha1_SyncJobOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -16,6 +16,7 @@ type RepositoryApplyConfiguration struct {
v1.TypeMetaApplyConfiguration `json:",inline"`
*v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"`
Spec *RepositorySpecApplyConfiguration `json:"spec,omitempty"`
Secure *SecureValuesApplyConfiguration `json:"secure,omitempty"`
Status *RepositoryStatusApplyConfiguration `json:"status,omitempty"`
}
@@ -196,6 +197,14 @@ func (b *RepositoryApplyConfiguration) WithSpec(value *RepositorySpecApplyConfig
return b
}
// WithSecure sets the Secure field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Secure field is set to the value of the last call.
func (b *RepositoryApplyConfiguration) WithSecure(value *SecureValuesApplyConfiguration) *RepositoryApplyConfiguration {
b.Secure = value
return b
}
// WithStatus sets the Status field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Status field is set to the value of the last call.
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Code generated by applyconfiguration-gen. DO NOT EDIT.
package v0alpha1
import (
commonv0alpha1 "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
// SecureValuesApplyConfiguration represents a declarative configuration of the SecureValues type for use
// with apply.
type SecureValuesApplyConfiguration struct {
Token *commonv0alpha1.InlineSecureValue `json:"token,omitempty"`
WebhookSecret *commonv0alpha1.InlineSecureValue `json:"webhookSecret,omitempty"`
}
// SecureValuesApplyConfiguration constructs a declarative configuration of the SecureValues type for use with
// apply.
func SecureValues() *SecureValuesApplyConfiguration {
return &SecureValuesApplyConfiguration{}
}
// WithToken sets the Token field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the Token field is set to the value of the last call.
func (b *SecureValuesApplyConfiguration) WithToken(value commonv0alpha1.InlineSecureValue) *SecureValuesApplyConfiguration {
b.Token = &value
return b
}
// WithWebhookSecret sets the WebhookSecret field in the declarative configuration to the given value
// and returns the receiver, so that objects can be built by chaining "With" function invocations.
// If called multiple times, the WebhookSecret field is set to the value of the last call.
func (b *SecureValuesApplyConfiguration) WithWebhookSecret(value commonv0alpha1.InlineSecureValue) *SecureValuesApplyConfiguration {
b.WebhookSecret = &value
return b
}
@@ -62,6 +62,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
return &provisioningv0alpha1.ResourceCountApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("ResourceRef"):
return &provisioningv0alpha1.ResourceRefApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("SecureValues"):
return &provisioningv0alpha1.SecureValuesApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("SyncJobOptions"):
return &provisioningv0alpha1.SyncJobOptionsApplyConfiguration{}
case v0alpha1.SchemeGroupVersion.WithKind("SyncOptions"):
+1 -1
View File
@@ -27,6 +27,7 @@ import (
_ "github.com/robfig/cron/v3"
_ "github.com/russellhaering/goxmldsig"
_ "github.com/spf13/cobra" // used by the standalone apiserver cli
_ "github.com/spyzhov/ajson"
_ "github.com/stretchr/testify/require"
_ "gocloud.dev/secrets/awskms"
_ "gocloud.dev/secrets/azurekeyvault"
@@ -52,5 +53,4 @@ import (
_ "github.com/grafana/e2e"
_ "github.com/grafana/gofpdf"
_ "github.com/grafana/gomemcache/memcache"
_ "github.com/spyzhov/ajson"
)
+9 -4
View File
@@ -1,11 +1,14 @@
package server
import (
"github.com/stretchr/testify/mock"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/secret"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/grpcserver"
@@ -14,7 +17,6 @@ import (
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/web"
"github.com/stretchr/testify/mock"
)
func ProvideTestEnv(
@@ -34,7 +36,8 @@ func ProvideTestEnv(
resourceClient resource.ResourceClient,
idService auth.IDService,
githubFactory *github.Factory,
repositorySecrets secrets.RepositorySecrets,
decryptService secret.DecryptService,
repositorySecrets secrets.RepositorySecrets, // TODO... remove
) (*TestEnv, error) {
return &TestEnv{
TestingT: testingT,
@@ -50,7 +53,8 @@ func ProvideTestEnv(
ResourceClient: resourceClient,
IDService: idService,
GitHubFactory: githubFactory,
RepositorySecrets: repositorySecrets,
DecryptService: decryptService,
RepositorySecrets: repositorySecrets, // TODO, remove
}, nil
}
@@ -72,5 +76,6 @@ type TestEnv struct {
ResourceClient resource.ResourceClient
IDService auth.IDService
GitHubFactory *github.Factory
RepositorySecrets secrets.RepositorySecrets
DecryptService secret.DecryptService
RepositorySecrets secrets.RepositorySecrets // NOTE, this will be removed soon
}
+1 -1
View File
@@ -1430,7 +1430,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, repositorySecrets)
testEnv, err := ProvideTestEnv(testingT, server, sqlStore, cfg, notificationServiceMock, grpcserverProvider, inMemory, httpclientProvider, oauthtokentestService, featureToggles, resourceClient, idimplService, factory, v3, repositorySecrets)
if err != nil {
return nil, err
}
@@ -2375,6 +2375,9 @@
"metadata": {
"default": {}
},
"secure": {
"default": {}
},
"spec": {
"default": {}
},
@@ -3464,6 +3467,14 @@
}
]
},
"secure": {
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.SecureValues"
}
]
},
"spec": {
"default": {},
"allOf": [
@@ -4193,6 +4204,30 @@
}
]
},
"com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.SecureValues": {
"description": "NOT YET USED FOR REAL -- testing secure value workflow",
"type": "object",
"properties": {
"token": {
"description": "Token used to connect the configured repository",
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue"
}
]
},
"webhookSecret": {
"description": "Some webhooks (github) require a secret key value",
"default": {},
"allOf": [
{
"$ref": "#/components/schemas/com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue"
}
]
}
}
},
"com.github.grafana.grafana.apps.provisioning.pkg.apis.provisioning.v0alpha1.SyncJobOptions": {
"type": "object",
"required": [
@@ -4406,6 +4441,45 @@
}
}
},
"com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.InlineSecureValue": {
"description": "Allow access to a secure value inside",
"oneOf": [
{
"required": [
"name"
]
},
{
"required": [
"create"
]
},
{
"required": [
"remove"
]
}
],
"properties": {
"create": {
"description": "Create a secure value -- this is only used for POST/PUT",
"type": "string",
"maxLength": 24576,
"minLength": 1
},
"name": {
"description": "Name in the secret service (reference)",
"type": "string",
"maxLength": 253,
"minLength": 1
},
"remove": {
"description": "Remove this value from the secure value map Values owned by this resource will be deleted if necessary",
"type": "boolean"
}
},
"additionalProperties": false
},
"com.github.grafana.grafana.pkg.apimachinery.apis.common.v0alpha1.Unstructured": {
"type": "object",
"additionalProperties": true,
+12 -18
View File
@@ -1,15 +1,13 @@
package apis
import (
"context"
"encoding/json"
"fmt"
"runtime"
"testing"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/version"
apimachineryversion "k8s.io/apimachinery/pkg/version"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/tests/testinfra"
@@ -38,18 +36,9 @@ func TestIntegrationOpenAPIs(t *testing.T) {
t.Run("check valid version response", func(t *testing.T) {
disco := h.NewDiscoveryClient()
req := disco.RESTClient().Get().
Prefix("version").
SetHeader("Accept", "application/json")
result := req.Do(context.Background())
require.NoError(t, result.Error())
raw, err := result.Raw()
require.NoError(t, err)
info := apimachineryversion.Info{}
err = json.Unmarshal(raw, &info)
info, err := disco.ServerVersion()
require.NoError(t, err)
require.Equal(t, runtime.Version(), info.GoVersion)
// Make sure the gitVersion is parsable
v, err := version.Parse(info.GitVersion)
@@ -57,10 +46,15 @@ func TestIntegrationOpenAPIs(t *testing.T) {
require.Equal(t, info.Major, fmt.Sprintf("%d", v.Major()))
require.Equal(t, info.Minor, fmt.Sprintf("%d", v.Minor()))
// Check that OpenAPI v2 (used by kubectl) returns properly
v2, err := disco.OpenAPISchema()
require.NoError(t, err, "requesting OpenAPI v2")
require.Equal(t, "Grafana API Server", v2.Info.Title)
// Check the v3 path resolves properly
// NOTE: fetching the v2 schema sometimes returns a 503 in our test infrastructure
// Removing the explicit `OneOf` properties from InlineSecureValue in:
// https://github.com/grafana/grafana/blob/main/pkg/apimachinery/apis/common/v0alpha1/secure_values.go#L78
// will consistently support V2, however kubectl and everything else continues to work
paths, err := disco.OpenAPIV3().Paths()
require.NoError(t, err, "requesting OpenAPI v3")
require.NotEmpty(t, paths, "has registered paths")
})
dir := "openapi_snapshots"
+94 -3
View File
@@ -9,14 +9,105 @@ import (
"testing"
"time"
"github.com/stretchr/testify/require"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestIntegrationProvisioning_InlineSecrets(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
helper := runGrafana(t, useAppPlatformSecrets)
createOptions := metav1.CreateOptions{FieldValidation: "Strict"}
ctx := context.Background()
decryptService := helper.GetEnv().DecryptService
require.NotNil(t, decryptService, "decrypt service wired properly")
type expectedField struct {
Path []string
DecryptedValue string // only try decrypting if not empty
}
tests := []struct {
name string
values map[string]any
inputFile string
expectedFields []expectedField
}{
{
name: "inline github token encrypted",
values: map[string]any{
"SecureTokenCreate": "some-token",
"SecureWebhookSecretCreate": "some-secret",
},
inputFile: "testdata/github-with-inline-secrets.json.tmpl",
expectedFields: []expectedField{
{
Path: []string{"secure", "token", "name"},
DecryptedValue: "some-token",
},
{
Path: []string{"secure", "webhookSecret", "name"},
DecryptedValue: "some-secret",
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := helper.RenderObject(t, test.inputFile, test.values)
obj, err := helper.Repositories.Resource.Create(ctx, input, createOptions)
require.NoError(t, err, "failed to create resource")
require.True(t, strings.HasPrefix(obj.GetName(), "test-"), "created a unique name")
var created []string
// Move encrypted token mutation
for _, expectedField := range test.expectedFields {
name, found, err := unstructured.NestedString(obj.Object, expectedField.Path...)
require.NoError(t, err, "error getting expected path")
require.True(t, found, expectedField.Path)
require.NotEmpty(t, name, expectedField.Path)
created = append(created, name)
if expectedField.DecryptedValue != "" {
decrypted, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", obj.GetNamespace(), name)
require.NoError(t, err, "decryption error")
require.Len(t, decrypted, 1)
val := decrypted[name].Value()
require.NotNil(t, val)
require.Equal(t, expectedField.DecryptedValue, val.DangerouslyExposeAndConsumeValue())
}
}
err = helper.Repositories.Resource.Delete(ctx, obj.GetName(), metav1.DeleteOptions{})
require.NoError(t, err, "failed to delete repository")
// Finalizers will be running async... so we need to wait until it is actually removed
require.Eventually(t, func() bool {
_, err := helper.Repositories.Resource.Get(ctx, obj.GetName(), metav1.GetOptions{})
return apierrors.IsNotFound(err)
}, time.Second*15, time.Millisecond*300, "should be removed")
// now check that we can no longer decrypt the requested values
results, err := decryptService.Decrypt(ctx, "provisioning.grafana.app", obj.GetNamespace(), created...)
require.NoError(t, err, "failed to execute decrypt with removed secrets")
for k, v := range results {
require.ErrorContains(t, v.Error(), "not found", "expecting not found error for all secrets: %s", k)
}
})
}
}
func TestIntegrationProvisioning_LegacySecrets(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
@@ -0,0 +1,23 @@
{
"apiVersion": "provisioning.grafana.app/v0alpha1",
"kind": "Repository",
"metadata": {
"generateName": "test-"
},
"spec": {
"title": "title",
"description": "something",
"type": "github",
"github": {
"url": "{{ or .URL "https://github.com/grafana/grafana-git-sync-demo" }}",
"branch": "{{ or .Branch "integration-test" }}",
"generateDashboardPreviews": {{ if .GenerateDashboardPreviews }} true {{ else }} false {{ end }},
"token": "{{ or .Token "" }}",
"path": "{{ or .Path "grafana/" }}"
}
},
"secure": {
"token": { "create": "{{ or .SecureTokenCreate "" }}" },
"webhookSecret": { "create": "{{ or .SecureWebhookSecretCreate "" }}" }
}
}