K8s/ManagedBy: Enforce who can CRUD provisioning resources (#103322)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user