PoC: Define userstorage API (#95557)
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
package userstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
genericapiserver "k8s.io/apiserver/pkg/server"
|
||||
"k8s.io/kube-openapi/pkg/common"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
userstorage "github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
)
|
||||
|
||||
var _ builder.APIGroupBuilder = (*UserStorageAPIBuilder)(nil)
|
||||
|
||||
type UserStorageAPIBuilder struct {
|
||||
registerer prometheus.Registerer
|
||||
}
|
||||
|
||||
func RegisterAPIService(features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, registerer prometheus.Registerer) *UserStorageAPIBuilder {
|
||||
if !features.IsEnabledGlobally(featuremgmt.FlagUserStorageAPI) {
|
||||
return nil
|
||||
}
|
||||
|
||||
builder := &UserStorageAPIBuilder{
|
||||
registerer: registerer,
|
||||
}
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
}
|
||||
|
||||
func (b *UserStorageAPIBuilder) GetGroupVersion() schema.GroupVersion {
|
||||
return userstorage.SchemeGroupVersion
|
||||
}
|
||||
|
||||
func (b *UserStorageAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
gv := userstorage.SchemeGroupVersion
|
||||
err := userstorage.AddToScheme(scheme)
|
||||
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"
|
||||
// addKnownTypes(scheme, schema.GroupVersion{
|
||||
// Group: userstorage.GROUP,
|
||||
// Version: runtime.APIVersionInternal,
|
||||
// })
|
||||
metav1.AddToGroupVersion(scheme, gv)
|
||||
return scheme.SetVersionPriority(gv)
|
||||
}
|
||||
|
||||
func (b *UserStorageAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
|
||||
resourceInfo := userstorage.UserStorageResourceInfo
|
||||
storage := map[string]rest.Storage{}
|
||||
|
||||
storageReg, err := newStorage(opts.Scheme, opts.OptsGetter, b.registerer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storage[resourceInfo.StoragePath()] = storageReg
|
||||
|
||||
apiGroupInfo.VersionedResourcesStorageMap[userstorage.VERSION] = storage
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *UserStorageAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
|
||||
return userstorage.GetOpenAPIDefinitions
|
||||
}
|
||||
|
||||
func (b *UserStorageAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *UserStorageAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
return authorizer.AuthorizerFunc(
|
||||
func(ctx context.Context, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
if !attr.IsResourceRequest() {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
|
||||
// require a user
|
||||
u, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
// check if is admin
|
||||
if u.GetIsGrafanaAdmin() {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
|
||||
switch attr.GetVerb() {
|
||||
case "create":
|
||||
// Create requests are validated later since we don't have access to the resource name
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
case "get", "delete", "patch", "update":
|
||||
// Only allow the user to access their own settings
|
||||
if !compareResourceNameAndUserUID(attr.GetName(), u) {
|
||||
return authorizer.DecisionDeny, "forbidden", nil
|
||||
}
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
default:
|
||||
// Forbid the rest
|
||||
return authorizer.DecisionDeny, "forbidden", nil
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package userstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
)
|
||||
|
||||
func TestAuthorizer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requesterID string
|
||||
verb string
|
||||
objectName string
|
||||
decision authorizer.Decision
|
||||
}{
|
||||
{
|
||||
name: "valid authorization",
|
||||
requesterID: "123",
|
||||
objectName: "user:123",
|
||||
verb: "get",
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "invalid user",
|
||||
requesterID: "123",
|
||||
objectName: "user:456",
|
||||
verb: "get",
|
||||
decision: authorizer.DecisionDeny,
|
||||
},
|
||||
{
|
||||
name: "admin user",
|
||||
requesterID: "admin",
|
||||
objectName: "",
|
||||
verb: "list",
|
||||
decision: authorizer.DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "create request",
|
||||
requesterID: "123",
|
||||
objectName: "",
|
||||
verb: "create",
|
||||
decision: authorizer.DecisionNoOpinion,
|
||||
},
|
||||
{
|
||||
name: "forbidden action",
|
||||
requesterID: "123",
|
||||
objectName: "",
|
||||
verb: "list",
|
||||
decision: authorizer.DecisionDeny,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
requester := &identity.StaticRequester{Type: "user", UserUID: tt.requesterID}
|
||||
if tt.requesterID == "admin" {
|
||||
requester.IsGrafanaAdmin = true
|
||||
}
|
||||
ctx := identity.WithRequester(context.Background(), requester)
|
||||
apiBuilder := &UserStorageAPIBuilder{}
|
||||
auth := apiBuilder.GetAuthorizer()
|
||||
at := &fakeAttributes{
|
||||
verb: tt.verb,
|
||||
name: tt.objectName,
|
||||
}
|
||||
decision, _, err := auth.Authorize(ctx, at)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.decision, decision)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAttributes struct {
|
||||
authorizer.Attributes
|
||||
verb string
|
||||
name string
|
||||
}
|
||||
|
||||
func (a fakeAttributes) GetVerb() string {
|
||||
return a.verb
|
||||
}
|
||||
|
||||
func (a fakeAttributes) IsResourceRequest() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (a fakeAttributes) GetName() string {
|
||||
return a.name
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package userstorage
|
||||
|
||||
import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apiserver/pkg/registry/generic"
|
||||
genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
|
||||
|
||||
userstorage "github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
var _ grafanarest.Storage = (*storage)(nil)
|
||||
|
||||
type storage struct {
|
||||
*genericregistry.Store
|
||||
}
|
||||
|
||||
func newStorage(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter, registerer prometheus.Registerer) (*storage, error) {
|
||||
resourceInfo := userstorage.UserStorageResourceInfo
|
||||
strategy := grafanaregistry.NewStrategy(scheme, resourceInfo.GroupVersion())
|
||||
storageStrategy := newStrategy(scheme, resourceInfo.GroupVersion(), registerer)
|
||||
|
||||
store := &genericregistry.Store{
|
||||
NewFunc: resourceInfo.NewFunc,
|
||||
NewListFunc: resourceInfo.NewListFunc,
|
||||
KeyRootFunc: grafanaregistry.KeyRootFunc(resourceInfo.GroupResource()),
|
||||
KeyFunc: grafanaregistry.NamespaceKeyFunc(resourceInfo.GroupResource()),
|
||||
PredicateFunc: grafanaregistry.Matcher,
|
||||
DefaultQualifiedResource: resourceInfo.GroupResource(),
|
||||
SingularQualifiedResource: resourceInfo.SingularGroupResource(),
|
||||
TableConvertor: resourceInfo.TableConverter(),
|
||||
CreateStrategy: storageStrategy,
|
||||
UpdateStrategy: storageStrategy,
|
||||
DeleteStrategy: strategy,
|
||||
}
|
||||
options := &generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: grafanaregistry.GetAttrs}
|
||||
if err := store.CompleteWithOptions(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &storage{Store: store}, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package userstorage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/apimachinery/pkg/util/validation/field"
|
||||
"k8s.io/apiserver/pkg/registry/rest"
|
||||
)
|
||||
|
||||
var (
|
||||
// Target for user storage size < 3MB
|
||||
userstorageSize = prometheus.NewHistogramVec(prometheus.HistogramOpts{
|
||||
Namespace: "userstorage",
|
||||
Name: "object_size_bytes",
|
||||
Help: "Histogram of user storage object sizes in bytes, broken down by service name",
|
||||
Buckets: prometheus.ExponentialBucketsRange(1024, 8*1024*1024, 8), // From 1 KB to 8 MB
|
||||
}, []string{"service"})
|
||||
)
|
||||
|
||||
type genericStrategy interface {
|
||||
rest.RESTCreateStrategy
|
||||
rest.RESTUpdateStrategy
|
||||
}
|
||||
|
||||
type userstorageStrategy struct {
|
||||
genericStrategy
|
||||
|
||||
registerer prometheus.Registerer
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
|
||||
func newStrategy(typer runtime.ObjectTyper, gv schema.GroupVersion, registerer prometheus.Registerer) *userstorageStrategy {
|
||||
once.Do(func() {
|
||||
if registerer != nil {
|
||||
registerer.MustRegister(
|
||||
userstorageSize,
|
||||
)
|
||||
}
|
||||
})
|
||||
genericStrategy := grafanaregistry.NewStrategy(typer, gv)
|
||||
return &userstorageStrategy{genericStrategy, registerer}
|
||||
}
|
||||
|
||||
func compareResourceNameAndUserUID(name string, u identity.Requester) bool {
|
||||
parsedName, err := parseName(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// u.GetUID() returns user:<user_uid> so we need to remove the user: prefix
|
||||
userUID := strings.Split(u.GetUID(), ":")
|
||||
if len(userUID) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return parsedName.UID == userUID[1]
|
||||
}
|
||||
|
||||
func registerSize(obj runtime.Object) {
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
parsedName, err := parseName(meta.GetName())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
b := new(bytes.Buffer)
|
||||
if err := json.NewEncoder(b).Encode(obj); err != nil {
|
||||
return
|
||||
}
|
||||
userstorageSize.WithLabelValues(parsedName.Service).Observe(float64(b.Len()))
|
||||
}
|
||||
|
||||
// Validate ensures that when creating a userstorage object, the name matches the user id.
|
||||
func (g *userstorageStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
|
||||
u, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return field.ErrorList{field.InternalError(nil, fmt.Errorf("failed to get requester: %v", err))}
|
||||
}
|
||||
|
||||
meta, err := utils.MetaAccessor(obj)
|
||||
if err != nil {
|
||||
return field.ErrorList{field.InternalError(nil, fmt.Errorf("failed to get meta accessor: %v", err))}
|
||||
}
|
||||
|
||||
nameMatch := compareResourceNameAndUserUID(meta.GetName(), u)
|
||||
if !nameMatch {
|
||||
return field.ErrorList{field.Forbidden(field.NewPath("metadata").Child("name"), "name must match service:user_uid")}
|
||||
}
|
||||
|
||||
registerSize(obj)
|
||||
return field.ErrorList{}
|
||||
}
|
||||
|
||||
func (g *userstorageStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
|
||||
registerSize(obj)
|
||||
return field.ErrorList{}
|
||||
}
|
||||
|
||||
type storageObjectName struct {
|
||||
Service string
|
||||
UID string
|
||||
}
|
||||
|
||||
func parseName(name string) (*storageObjectName, error) {
|
||||
vals := strings.Split(name, ":")
|
||||
if len(vals) != 2 {
|
||||
return nil, errors.New("name must be in the format <service>:<user_uid>")
|
||||
}
|
||||
|
||||
return &storageObjectName{
|
||||
Service: vals[0],
|
||||
UID: vals[1],
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package userstorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apis/userstorage/v0alpha1"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requesterID string
|
||||
objectName string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid userstorage object",
|
||||
requesterID: "123",
|
||||
objectName: "basic-panel:123",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid userstorage object",
|
||||
requesterID: "123",
|
||||
objectName: "basic-panel:456",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
requester := &identity.StaticRequester{Type: "user", UserUID: tt.requesterID}
|
||||
obj := &v0alpha1.UserStorage{
|
||||
ObjectMeta: v1.ObjectMeta{
|
||||
Name: tt.objectName,
|
||||
},
|
||||
}
|
||||
ctx := identity.WithRequester(context.Background(), requester)
|
||||
|
||||
strategy := newStrategy(nil, schema.GroupVersion{}, prometheus.DefaultRegisterer)
|
||||
errs := strategy.Validate(ctx, obj)
|
||||
|
||||
if tt.expectError {
|
||||
assert.NotEmpty(t, errs)
|
||||
} else {
|
||||
assert.Empty(t, errs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user