Secrets: Add inline secure value can reference method (#108965)

This commit is contained in:
Matheus Macabu
2025-07-31 16:42:40 +02:00
committed by GitHub
parent d2c008c96e
commit cfb5f5601f
4 changed files with 480 additions and 1 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ func (u *StaticRequester) GetExtra() map[string][]string {
}
result := map[string][]string{}
if u.AccessTokenClaims.Rest.ServiceIdentity != "" {
if u.AccessTokenClaims != nil && u.AccessTokenClaims.Rest.ServiceIdentity != "" {
result[authnlib.ServiceIdentityKey] = []string{u.AccessTokenClaims.Rest.ServiceIdentity}
}
return result
@@ -0,0 +1,19 @@
package contracts
import (
"context"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
)
type InlineSecureValueSupport interface {
// Check that the request user can reference a secret in the context of a given resource (owner)
CanReference(ctx context.Context, owner common.ObjectReference, values common.InlineSecureValues) error
// CreateInline creates a secret that is owned by the referenced object
// returns the name of the created secret or an error
CreateInline(ctx context.Context, owner common.ObjectReference, value common.RawSecureValue) (string, error)
// DeleteWhenOwnedByResource removes secrets if and only if they are owned by a referenced object
DeleteWhenOwnedByResource(ctx context.Context, owner common.ObjectReference, name string) error
}
@@ -0,0 +1,166 @@
package service
import (
"context"
"errors"
"fmt"
authlib "github.com/grafana/authlib/types"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"k8s.io/apimachinery/pkg/runtime/schema"
secretv1beta1 "github.com/grafana/grafana/apps/secret/pkg/apis/secret/v1beta1"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/secret/contracts"
"github.com/grafana/grafana/pkg/registry/apis/secret/xkube"
)
type inlineSecureValueService struct {
tracer trace.Tracer
secureValueService contracts.SecureValueService
accessChecker authlib.AccessChecker
}
func ProvideInlineSecureValueService(
tracer trace.Tracer,
secureValueService contracts.SecureValueService,
accessClient authlib.AccessClient,
) contracts.InlineSecureValueSupport {
return &inlineSecureValueService{
tracer: tracer,
secureValueService: secureValueService,
accessChecker: accessClient,
}
}
func (s *inlineSecureValueService) CanReference(ctx context.Context, owner common.ObjectReference, values common.InlineSecureValues) error {
ctx, span := s.tracer.Start(ctx, "InlineSecureValueService.CanReference", trace.WithAttributes(
attribute.String("owner.namespace", owner.Namespace),
attribute.String("owner.apiGroup", owner.APIGroup),
attribute.String("owner.apiVersion", owner.APIVersion),
attribute.String("owner.kind", owner.Kind),
attribute.String("owner.name", owner.Name),
))
defer span.End()
authInfo, ok := authlib.AuthInfoFrom(ctx)
if !ok {
return fmt.Errorf("missing auth info in context")
}
if owner.Namespace == "" || !authlib.NamespaceMatches(authInfo.GetNamespace(), owner.Namespace) {
return fmt.Errorf("owner namespace %s does not match auth info namespace %s", owner.Namespace, authInfo.GetNamespace())
}
if owner.APIGroup == "" || owner.APIVersion == "" || owner.Kind == "" || owner.Name == "" {
return fmt.Errorf("owner reference must have a valid API group, API version, kind and name")
}
if len(values) == 0 {
return fmt.Errorf("no inline secure values provided")
}
for field, value := range values {
if value.Name == "" {
return fmt.Errorf("field %s has an empty secure value name", field)
}
if !value.Create.IsZero() {
return fmt.Errorf("field %s has 'create' set, which is not allowed", field)
}
if value.Remove {
return fmt.Errorf("field %s has 'remove' set, which is not allowed", field)
}
owned, err := s.isSecureValueOwnedByResource(ctx, owner, value.Name)
if err != nil {
return fmt.Errorf("field %s had an error checking secure value ownership: %w", field, err)
}
if !owned {
if err := s.canIdentityReadSecureValue(ctx, xkube.Namespace(owner.Namespace), value.Name); err != nil {
return fmt.Errorf("field %s: identity cannot read secure value %s: %w", field, value.Name, err)
}
}
}
return nil
}
func (s *inlineSecureValueService) isSecureValueOwnedByResource(ctx context.Context, owner common.ObjectReference, name string) (bool, error) {
sv, err := s.secureValueService.Read(ctx, xkube.Namespace(owner.Namespace), name)
if err != nil {
if errors.Is(err, contracts.ErrSecureValueNotFound) {
return false, err
}
return false, fmt.Errorf("error reading secure value %s: %w", name, err)
}
secureValueOwners := sv.GetOwnerReferences()
if len(secureValueOwners) > 1 {
return false, fmt.Errorf("bug found: secure value %s with multiple owners, expected only one", name)
}
if len(secureValueOwners) == 1 {
actualOwner := secureValueOwners[0]
gv, err := schema.ParseGroupVersion(actualOwner.APIVersion)
if err != nil {
return false, fmt.Errorf("bug found: secure value %s should have valid group version here: %w", name, err)
}
if gv.Group == "" {
return false, fmt.Errorf("bug found: secure value %s should have a non-empty group in the owner reference", name)
}
sameOwner := owner.APIGroup == gv.Group && owner.Kind == actualOwner.Kind && owner.Name == actualOwner.Name
if sameOwner {
return true, nil // The secure value is owned by the same owner reference, pass!
}
return false, fmt.Errorf("secure value %s is not owned by %v but by %v", name, owner, actualOwner)
}
// not owned
return false, nil
}
func (s *inlineSecureValueService) canIdentityReadSecureValue(ctx context.Context, namespace xkube.Namespace, name string) error {
authInfo, ok := authlib.AuthInfoFrom(ctx)
if !ok {
return fmt.Errorf("missing auth info in context")
}
// If the secure value is shared, we always need a user/svc account in the context.
if authInfo.GetIdentityType() != authlib.TypeUser && authInfo.GetIdentityType() != authlib.TypeServiceAccount {
return fmt.Errorf("identity type %s not allowed, expected either %s or %s", authInfo.GetIdentityType(), authlib.TypeUser, authlib.TypeServiceAccount)
}
resp, err := s.accessChecker.Check(ctx, authInfo, authlib.CheckRequest{
Verb: utils.VerbGet,
Group: secretv1beta1.APIGroup,
Resource: secretv1beta1.SecureValuesResourceInfo.GroupResource().Resource,
Namespace: namespace.String(),
Name: name,
})
if err != nil {
return fmt.Errorf("checking access for secure value %s: %w", name, err)
}
if !resp.Allowed {
return fmt.Errorf("identity is not allowed to reference secure value %s", name)
}
return nil
}
func (s *inlineSecureValueService) CreateInline(ctx context.Context, owner common.ObjectReference, value common.RawSecureValue) (string, error) {
return "", fmt.Errorf("not implemented yet")
}
func (s *inlineSecureValueService) DeleteWhenOwnedByResource(ctx context.Context, owner common.ObjectReference, name string) error {
return fmt.Errorf("not implemented yet")
}
@@ -0,0 +1,294 @@
package service_test
import (
"testing"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apis/secret/service"
"github.com/grafana/grafana/pkg/registry/apis/secret/testutils"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace/noop"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestIntegration_InlineSecureValue_CanReference(t *testing.T) {
t.Parallel()
tracer := noop.NewTracerProvider().Tracer("test")
defaultNs := "org-1234"
owner := common.ObjectReference{
APIGroup: "prometheus.datasource.grafana.app",
APIVersion: "v1alpha1",
Kind: "DataSourceConfig",
Name: "test-datasource",
Namespace: defaultNs,
}
t.Run("happy path with owned and shared secure values", func(t *testing.T) {
t.Parallel()
tu := testutils.Setup(t)
sv1 := "test-secure-value-1"
createdSv1, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) {
cfg.Sv.Name = sv1
cfg.Sv.Namespace = defaultNs
cfg.Sv.OwnerReferences = []metav1.OwnerReference{owner.ToOwnerReference()}
})
require.NoError(t, err)
require.NotNil(t, createdSv1)
sv2 := "test-secure-value-2"
createdSv2, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) {
cfg.Sv.Name = sv2
cfg.Sv.Namespace = defaultNs
})
require.NoError(t, err)
require.NotNil(t, createdSv2)
values := common.InlineSecureValues{
"fieldA": {Name: sv1},
"fieldB": {Name: sv2},
}
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{
"securevalues:read": {"securevalues:uid:" + sv2},
})
svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, tu.AccessClient)
err = svc.CanReference(ctx, owner, values)
require.NoError(t, err)
})
t.Run("when the auth info is missing it returns an error", func(t *testing.T) {
t.Parallel()
svc := service.ProvideInlineSecureValueService(tracer, nil, nil)
err := svc.CanReference(t.Context(), common.ObjectReference{}, common.InlineSecureValues{})
require.Error(t, err)
})
t.Run("when the owner namespace does not match auth info namespace it returns an error", func(t *testing.T) {
t.Parallel()
svc := service.ProvideInlineSecureValueService(tracer, nil, nil)
reqNs := "org-2345"
ctx := testutils.CreateUserAuthContext(t.Context(), reqNs, map[string][]string{})
err := svc.CanReference(ctx, owner, common.InlineSecureValues{})
require.Error(t, err)
})
t.Run("when the owner namespace is empty it returns an error", func(t *testing.T) {
t.Parallel()
svc := service.ProvideInlineSecureValueService(tracer, nil, nil)
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{})
err := svc.CanReference(ctx, common.ObjectReference{}, common.InlineSecureValues{})
require.Error(t, err)
})
t.Run("when the owner reference has empty fields it returns an error", func(t *testing.T) {
t.Parallel()
svc := service.ProvideInlineSecureValueService(tracer, nil, nil)
owner := common.ObjectReference{
Namespace: defaultNs,
}
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{})
err := svc.CanReference(ctx, owner, common.InlineSecureValues{})
require.Error(t, err)
owner.APIGroup = "prometheus.datasource.grafana.app"
require.Error(t, svc.CanReference(ctx, owner, common.InlineSecureValues{}))
owner.APIGroup = ""
owner.APIVersion = "v1alpha1"
require.Error(t, svc.CanReference(ctx, owner, common.InlineSecureValues{}))
owner.APIVersion = ""
owner.Kind = "DataSourceConfig"
require.Error(t, svc.CanReference(ctx, owner, common.InlineSecureValues{}))
owner.Kind = ""
owner.Name = "test-datasource"
require.Error(t, svc.CanReference(ctx, owner, common.InlineSecureValues{}))
owner.Name = ""
})
t.Run("when no secure values are provided it returns an error", func(t *testing.T) {
t.Parallel()
svc := service.ProvideInlineSecureValueService(tracer, nil, nil)
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{})
err := svc.CanReference(ctx, owner, common.InlineSecureValues{})
require.Error(t, err)
})
t.Run("when one of the secure values does not have a `name`, it returns an error", func(t *testing.T) {
t.Parallel()
svc := service.ProvideInlineSecureValueService(tracer, nil, nil)
values := common.InlineSecureValues{
"fieldA": {Name: ""},
}
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{})
err := svc.CanReference(ctx, owner, values)
require.Error(t, err)
})
t.Run("when one of the secure values has `create` field set, it returns an error", func(t *testing.T) {
t.Parallel()
svc := service.ProvideInlineSecureValueService(tracer, nil, nil)
values := common.InlineSecureValues{
"fieldA": {
Name: "test-sv",
Create: common.NewSecretValue("test"),
},
}
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{})
err := svc.CanReference(ctx, owner, values)
require.Error(t, err)
})
t.Run("when one of the secure values has `remove` field set, it returns an error", func(t *testing.T) {
t.Parallel()
svc := service.ProvideInlineSecureValueService(tracer, nil, nil)
values := common.InlineSecureValues{
"fieldA": {
Name: "test-sv",
Remove: true,
},
}
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{})
err := svc.CanReference(ctx, owner, values)
require.Error(t, err)
})
t.Run("when the secure value does not exist, it returns an error", func(t *testing.T) {
t.Parallel()
tu := testutils.Setup(t)
svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil)
values := common.InlineSecureValues{
"fieldA": {Name: "non-existent-sv"},
}
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{})
err := svc.CanReference(ctx, owner, values)
require.Error(t, err)
})
t.Run("when the secure value is owned by a different resource, it returns an error", func(t *testing.T) {
t.Parallel()
tu := testutils.Setup(t)
differentOwner := common.ObjectReference{
APIGroup: "prometheus.datasource.grafana.app",
APIVersion: "v1alpha1",
Kind: "DataSourceConfig",
Name: "different-datasource",
Namespace: defaultNs,
}
sv1 := "test-secure-value-1"
createdSv1, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) {
cfg.Sv.Name = sv1
cfg.Sv.Namespace = defaultNs
cfg.Sv.OwnerReferences = []metav1.OwnerReference{differentOwner.ToOwnerReference()}
})
require.NoError(t, err)
require.NotNil(t, createdSv1)
values := common.InlineSecureValues{
"fieldA": {Name: sv1},
}
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{})
svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil)
err = svc.CanReference(ctx, owner, values)
require.Error(t, err)
})
t.Run("when the request identity is not a user nor a service account, it returns an error", func(t *testing.T) {
t.Parallel()
tu := testutils.Setup(t)
sv1 := "test-secure-value-1"
_, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) {
cfg.Sv.Name = sv1
cfg.Sv.Namespace = defaultNs
})
require.NoError(t, err)
values := common.InlineSecureValues{
"fieldA": {Name: sv1},
}
ctx := identity.WithServiceIdentityContext(t.Context(), 1234)
svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, nil)
err = svc.CanReference(ctx, owner, values)
require.Error(t, err)
})
t.Run("when the identity does not have permissions to read the secure value, it returns an error", func(t *testing.T) {
t.Parallel()
tu := testutils.Setup(t)
sv1 := "test-secure-value-1"
_, err := tu.CreateSv(t.Context(), func(cfg *testutils.CreateSvConfig) {
cfg.Sv.Name = sv1
cfg.Sv.Namespace = defaultNs
})
require.NoError(t, err)
values := common.InlineSecureValues{
"fieldA": {Name: sv1},
}
svc := service.ProvideInlineSecureValueService(tracer, tu.SecureValueService, tu.AccessClient)
ctx := testutils.CreateUserAuthContext(t.Context(), defaultNs, map[string][]string{
"securevalues:read": {"securevalues:uid:another-sv"}, // can read, but another resource!
})
err = svc.CanReference(ctx, owner, values)
require.Error(t, err)
ctx = testutils.CreateUserAuthContext(t.Context(), defaultNs, nil)
err = svc.CanReference(ctx, owner, values)
require.Error(t, err)
})
}