K8s/ManagedBy: Enforce who can CRUD provisioning resources (#103322)

This commit is contained in:
Ryan McKinley
2025-04-08 14:17:33 +03:00
committed by GitHub
parent 577ea8f6a9
commit d3e6e308a0
8 changed files with 290 additions and 8 deletions
+88
View File
@@ -0,0 +1,88 @@
package apistore
import (
"net/http"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
authtypes "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
func checkManagerPropertiesOnDelete(auth authtypes.AuthInfo, obj utils.GrafanaMetaAccessor) error {
return enforceManagerProperties(auth, obj)
}
func checkManagerPropertiesOnCreate(auth authtypes.AuthInfo, obj utils.GrafanaMetaAccessor) error {
return enforceManagerProperties(auth, obj)
}
func checkManagerPropertiesOnUpdateSpec(auth authtypes.AuthInfo, obj utils.GrafanaMetaAccessor, old utils.GrafanaMetaAccessor) error {
objKind := obj.GetAnnotation(utils.AnnoKeyManagerKind)
oldKind := old.GetAnnotation(utils.AnnoKeyManagerKind)
if objKind == "" && objKind == oldKind {
return nil // not managed
}
// Check the current settings
err := checkManagerPropertiesOnCreate(auth, obj)
if err != nil { // new settings failed
return err
}
managerNew, okNew := obj.GetManagerProperties()
managerOld, okOld := old.GetManagerProperties()
if managerNew == managerOld || (okNew && !okOld) { // added manager is OK
return nil
}
if !okNew && okOld {
// This allows removing the managedBy annotations if you were allowed to write them originally
if err := checkManagerPropertiesOnCreate(auth, old); err != nil {
return &apierrors.StatusError{ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusForbidden,
Reason: metav1.StatusReasonForbidden,
Message: "Can not remove resource manager from resource",
}}
}
}
return nil
}
func enforceManagerProperties(auth authtypes.AuthInfo, obj utils.GrafanaMetaAccessor) error {
kind := obj.GetAnnotation(utils.AnnoKeyManagerKind)
if kind == "" {
return nil
}
switch utils.ParseManagerKindString(kind) {
case utils.ManagerKindUnknown:
return nil // not managed
case utils.ManagerKindRepo:
if auth.GetUID() == "access-policy:provisioning" {
return nil // OK!
}
return &apierrors.StatusError{ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusForbidden,
Reason: metav1.StatusReasonForbidden,
Message: "Provisioned resources must be manaaged by the provisioning service account",
}}
case utils.ManagerKindPlugin, utils.ManagerKindClassicFP: // nolint:staticcheck
// ?? what identity do we use for legacy internal requests?
return nil // no error
case utils.ManagerKindTerraform, utils.ManagerKindKubectl:
manager, _ := obj.GetManagerProperties()
if manager.AllowsEdits {
return nil // no error anyone can do it
}
// TODO: check the kubectl+terraform resource
return nil
}
return nil
}
@@ -0,0 +1,136 @@
package apistore
import (
"context"
"testing"
"github.com/stretchr/testify/require"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
authtypes "github.com/grafana/authlib/types"
dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
func TestManagedAuthorizer(t *testing.T) {
user := &identity.StaticRequester{Type: authtypes.TypeUser, UserUID: "uuu"}
_, provisioner, err := identity.WithProvisioningIdentity(context.Background(), "default")
require.NoError(t, err)
tests := []struct {
name string
auth authtypes.AuthInfo
obj runtime.Object
old runtime.Object
err string
}{
{
name: "user can create",
auth: user,
obj: &unstructured.Unstructured{},
},
{
name: "provisioning can create",
auth: provisioner,
obj: &dashboard.Dashboard{
ObjectMeta: v1.ObjectMeta{
Annotations: map[string]string{
utils.AnnoKeyManagerKind: string(utils.ManagerKindRepo),
utils.AnnoKeyManagerIdentity: "abc",
},
},
},
},
{
name: "user can not create provisioned resource",
auth: user,
err: "Provisioned resources must be manaaged by the provisioning service account",
obj: &dashboard.Dashboard{
ObjectMeta: v1.ObjectMeta{
Annotations: map[string]string{
utils.AnnoKeyManagerKind: string(utils.ManagerKindRepo),
utils.AnnoKeyManagerIdentity: "abc",
},
},
},
},
{
name: "user can not update provisioned resource",
auth: user,
err: "Provisioned resources must be manaaged by the provisioning service account",
obj: &dashboard.Dashboard{
ObjectMeta: v1.ObjectMeta{
Generation: 1,
},
},
old: &dashboard.Dashboard{
ObjectMeta: v1.ObjectMeta{
Generation: 2,
Annotations: map[string]string{
utils.AnnoKeyManagerKind: string(utils.ManagerKindRepo),
utils.AnnoKeyManagerIdentity: "abc",
},
},
},
},
{
name: "provisioner can remove manager flags",
auth: provisioner,
obj: &dashboard.Dashboard{
ObjectMeta: v1.ObjectMeta{
Generation: 1,
},
},
old: &dashboard.Dashboard{
ObjectMeta: v1.ObjectMeta{
Generation: 2,
Annotations: map[string]string{
utils.AnnoKeyManagerKind: string(utils.ManagerKindRepo),
utils.AnnoKeyManagerIdentity: "abc",
},
},
},
},
{
name: "provisioner can add manager flags",
auth: provisioner,
old: &dashboard.Dashboard{
ObjectMeta: v1.ObjectMeta{
Generation: 1,
},
},
obj: &dashboard.Dashboard{
ObjectMeta: v1.ObjectMeta{
Generation: 2,
Annotations: map[string]string{
utils.AnnoKeyManagerKind: string(utils.ManagerKindRepo),
utils.AnnoKeyManagerIdentity: "abc",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obj, err := utils.MetaAccessor(tt.obj)
require.NoError(t, err)
if tt.old == nil {
err = checkManagerPropertiesOnCreate(tt.auth, obj)
} else {
old, _ := utils.MetaAccessor(tt.old)
err = checkManagerPropertiesOnUpdateSpec(tt.auth, obj, old)
}
if tt.err != "" {
require.Error(t, err, tt.err)
} else {
require.NoError(t, err)
}
})
}
}
+8
View File
@@ -61,6 +61,9 @@ func (s *Storage) prepareObjectForStorage(ctx context.Context, newObject runtime
if obj.GetFolder() != "" && !s.opts.EnableFolderSupport {
return nil, apierrors.NewBadRequest(fmt.Sprintf("folders are not supported for: %s", s.gr.String()))
}
if err := checkManagerPropertiesOnCreate(info, obj); err != nil {
return nil, err
}
if s.opts.RequireDeprecatedInternalID {
// nolint:staticcheck
@@ -160,6 +163,11 @@ func (s *Storage) prepareObjectForUpdate(ctx context.Context, updateObject runti
obj.SetGeneration(previous.GetGeneration() + 1)
obj.SetUpdatedBy(info.GetUID())
obj.SetUpdatedTimestampMillis(time.Now().UnixMilli())
// Only validate when the generation has changed
if err := checkManagerPropertiesOnUpdateSpec(info, obj, previous); err != nil {
return nil, err
}
} else {
obj.SetGeneration(previous.GetGeneration())
obj.SetAnnotation(utils.AnnoKeyUpdatedBy, previous.GetAnnotation(utils.AnnoKeyUpdatedBy))
@@ -87,6 +87,9 @@ func TestPrepareObjectForStorage(t *testing.T) {
})
t.Run("Should keep manager info", func(t *testing.T) {
ctx, _, err := identity.WithProvisioningIdentity(ctx, "default")
require.NoError(t, err)
dashboard := v1alpha1.Dashboard{}
dashboard.Name = "test-name"
obj := dashboard.DeepCopyObject()
+15
View File
@@ -31,6 +31,7 @@ import (
"github.com/bwmarrin/snowflake"
authtypes "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/apiserver/rest"
@@ -219,6 +220,11 @@ func (s *Storage) Delete(
_ runtime.Object,
opts storage.DeleteOptions,
) error {
info, ok := authtypes.AuthInfoFrom(ctx)
if !ok {
return errors.New("missing auth info")
}
if err := s.Get(ctx, key, storage.GetOptions{}, out); err != nil {
return err
}
@@ -250,6 +256,15 @@ func (s *Storage) Delete(
return err
}
}
meta, err := utils.MetaAccessor(out)
if err != nil {
return fmt.Errorf("unable to read object %w", err)
}
if err = checkManagerPropertiesOnDelete(info, meta); err != nil {
return err
}
rsp, err := s.store.Delete(ctx, cmd)
if err != nil {
return resource.GetError(resource.AsErrorResult(err))