diff --git a/pkg/apis/playlist/v0alpha1/conversions.go b/pkg/apis/playlist/v0alpha1/conversions.go index fc393ffcae3..c3cb0b95222 100644 --- a/pkg/apis/playlist/v0alpha1/conversions.go +++ b/pkg/apis/playlist/v0alpha1/conversions.go @@ -8,6 +8,7 @@ import ( "k8s.io/apimachinery/pkg/types" "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/setting" ) type namespaceMapper = func(orgId int64) string @@ -19,6 +20,13 @@ func orgNamespaceMapper(orgId int64) string { return fmt.Sprintf("org-%d", orgId) } +func getNamespaceMapper(cfg *setting.Cfg) namespaceMapper { + if cfg.StackID != "" { + return func(orgId int64) string { return "stack-" + cfg.StackID } + } + return orgNamespaceMapper +} + func convertToK8sResource(v *playlist.PlaylistDTO, namespacer namespaceMapper) *Playlist { spec := Spec{ Title: v.Name, diff --git a/pkg/apis/playlist/v0alpha1/conversions_test.go b/pkg/apis/playlist/v0alpha1/conversions_test.go index 48f63dde95a..f8809ca050c 100644 --- a/pkg/apis/playlist/v0alpha1/conversions_test.go +++ b/pkg/apis/playlist/v0alpha1/conversions_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/setting" ) func TestPlaylistConversion(t *testing.T) { @@ -62,3 +63,36 @@ func TestPlaylistConversion(t *testing.T) { } }`, string(out)) } + +func TestNamespaceMapper(t *testing.T) { + tests := []struct { + name string + cfg string + orgId int64 + expected string + }{ + { + name: "default namespace", + orgId: 1, + expected: "default", + }, + { + name: "with org", + orgId: 123, + expected: "org-123", + }, + { + name: "with stackId", + cfg: "abc", + orgId: 123, // ignored + expected: "stack-abc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mapper := getNamespaceMapper(&setting.Cfg{StackID: tt.cfg}) + require.Equal(t, tt.expected, mapper(tt.orgId)) + }) + } +} diff --git a/pkg/apis/playlist/v0alpha1/legacy_storage.go b/pkg/apis/playlist/v0alpha1/legacy_storage.go index d476b6a228c..11af7806457 100644 --- a/pkg/apis/playlist/v0alpha1/legacy_storage.go +++ b/pkg/apis/playlist/v0alpha1/legacy_storage.go @@ -22,13 +22,8 @@ var ( ) type legacyStorage struct { - service playlist.Service -} - -func newLegacyStorage(s playlist.Service) *legacyStorage { - return &legacyStorage{ - service: s, - } + service playlist.Service + namespacer namespaceMapper } func (s *legacyStorage) New() runtime.Object { @@ -56,9 +51,9 @@ func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Objec func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListOptions) (runtime.Object, error) { // TODO: handle fetching all available orgs when no namespace is specified // To test: kubectl get playlists --all-namespaces - orgId, ok := grafanarequest.OrgIDFrom(ctx) - if !ok { - orgId = 1 // TODO: default org ID 1 for now + info, err := grafanarequest.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err } limit := 100 @@ -66,7 +61,7 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO limit = int(options.Limit) } res, err := s.service.Search(ctx, &playlist.GetPlaylistsQuery{ - OrgId: orgId, + OrgId: info.OrgID, Limit: limit, }) if err != nil { @@ -82,12 +77,12 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO for _, v := range res { p, err := s.service.Get(ctx, &playlist.GetPlaylistByUidQuery{ UID: v.UID, - OrgId: orgId, // required + OrgId: info.OrgID, }) if err != nil { return nil, err } - list.Items = append(list.Items, *convertToK8sResource(p, orgNamespaceMapper)) + list.Items = append(list.Items, *convertToK8sResource(p, s.namespacer)) } if len(list.Items) == limit { list.Continue = "" // TODO? @@ -96,14 +91,14 @@ func (s *legacyStorage) List(ctx context.Context, options *internalversion.ListO } func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - orgId, ok := grafanarequest.OrgIDFrom(ctx) - if !ok { - orgId = 1 // TODO: default org ID 1 for now + info, err := grafanarequest.NamespaceInfoFrom(ctx, true) + if err != nil { + return nil, err } dto, err := s.service.Get(ctx, &playlist.GetPlaylistByUidQuery{ UID: name, - OrgId: orgId, + OrgId: info.OrgID, }) if err != nil { return nil, err @@ -112,5 +107,5 @@ func (s *legacyStorage) Get(ctx context.Context, name string, options *metav1.Ge return nil, fmt.Errorf("not found?") } - return convertToK8sResource(dto, orgNamespaceMapper), nil + return convertToK8sResource(dto, s.namespacer), nil } diff --git a/pkg/apis/playlist/v0alpha1/register.go b/pkg/apis/playlist/v0alpha1/register.go index 9a7335f5251..f77d0148ea8 100644 --- a/pkg/apis/playlist/v0alpha1/register.go +++ b/pkg/apis/playlist/v0alpha1/register.go @@ -13,18 +13,24 @@ import ( grafanaapiserver "github.com/grafana/grafana/pkg/services/grafana-apiserver" grafanarest "github.com/grafana/grafana/pkg/services/grafana-apiserver/rest" "github.com/grafana/grafana/pkg/services/playlist" + "github.com/grafana/grafana/pkg/setting" ) var _ grafanaapiserver.APIGroupBuilder = (*PlaylistAPIBuilder)(nil) // This is used just so wire has something unique to return type PlaylistAPIBuilder struct { - service playlist.Service + service playlist.Service + namespacer namespaceMapper } -func RegisterAPIService(p playlist.Service, apiregistration grafanaapiserver.APIRegistrar) *PlaylistAPIBuilder { +func RegisterAPIService(p playlist.Service, + apiregistration grafanaapiserver.APIRegistrar, + cfg *setting.Cfg, +) *PlaylistAPIBuilder { builder := &PlaylistAPIBuilder{ - service: p, + service: p, + namespacer: getNamespaceMapper(cfg), } apiregistration.RegisterAPI(builder) return builder @@ -50,7 +56,10 @@ func (b *PlaylistAPIBuilder) GetAPIGroupInfo( apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(GroupName, scheme, metav1.ParameterCodec, codecs) storage := map[string]rest.Storage{} - legacyStore := newLegacyStorage(b.service) + legacyStore := &legacyStorage{ + service: b.service, + namespacer: b.namespacer, + } storage["playlists"] = legacyStore // enable dual writes if a RESTOptionsGetter is provided diff --git a/pkg/services/grafana-apiserver/auth/authorizer/org/org_id.go b/pkg/services/grafana-apiserver/auth/authorizer/org/org_id.go index b070af3fb68..fc0fc887fa5 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/org/org_id.go +++ b/pkg/services/grafana-apiserver/auth/authorizer/org/org_id.go @@ -20,7 +20,10 @@ type OrgIDAuthorizer struct { } func ProvideOrgIDAuthorizer(orgService org.Service) *OrgIDAuthorizer { - return &OrgIDAuthorizer{log: log.New("grafana-apiserver.authorizer.orgid"), org: orgService} + return &OrgIDAuthorizer{ + log: log.New("grafana-apiserver.authorizer.orgid"), + org: orgService, + } } func (auth OrgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { @@ -29,11 +32,26 @@ func (auth OrgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attribut return authorizer.DecisionDeny, fmt.Sprintf("error getting signed in user: %v", err), nil } - orgID, ok := grafanarequest.ParseOrgID(a.GetNamespace()) - if !ok { + info, err := grafanarequest.ParseNamespace(a.GetNamespace()) + if err != nil { + return authorizer.DecisionDeny, fmt.Sprintf("error reading namespace: %v", err), nil + } + + // No opinion when the namespace is arbitrary + if info.OrgID == -1 { return authorizer.DecisionNoOpinion, "", nil } + if info.StackID != "" { + return authorizer.DecisionDeny, "using a stack namespace requires deployment with a fixed stack id", nil + } + + // Quick check that the same org is used + if signedInUser.OrgID == info.OrgID { + return authorizer.DecisionAllow, "", nil + } + + // Check if the user has access to the specified org query := org.GetUserOrgListQuery{UserID: signedInUser.UserID} result, err := auth.org.GetUserOrgList(ctx, &query) if err != nil { @@ -41,10 +59,10 @@ func (auth OrgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attribut } for _, org := range result { - if org.OrgID == orgID { + if org.OrgID == info.OrgID { return authorizer.DecisionAllow, "", nil } } - return authorizer.DecisionDeny, fmt.Sprintf("user %d is not a member of org %d", signedInUser.UserID, orgID), nil + return authorizer.DecisionDeny, fmt.Sprintf("user %d is not a member of org %d", signedInUser.UserID, info.OrgID), nil } diff --git a/pkg/services/grafana-apiserver/auth/authorizer/org/org_role.go b/pkg/services/grafana-apiserver/auth/authorizer/org/org_role.go index d9d8f29f11b..b2571c4909a 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/org/org_role.go +++ b/pkg/services/grafana-apiserver/auth/authorizer/org/org_role.go @@ -4,22 +4,20 @@ import ( "context" "fmt" - "k8s.io/apiserver/pkg/authorization/authorizer" - "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/org" + "k8s.io/apiserver/pkg/authorization/authorizer" ) var _ authorizer.Authorizer = &OrgIDAuthorizer{} type OrgRoleAuthorizer struct { log log.Logger - org org.Service } func ProvideOrgRoleAuthorizer(orgService org.Service) *OrgRoleAuthorizer { - return &OrgRoleAuthorizer{log: log.New("grafana-apiserver.authorizer.orgrole"), org: orgService} + return &OrgRoleAuthorizer{log: log.New("grafana-apiserver.authorizer.orgrole")} } func (auth OrgRoleAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { diff --git a/pkg/services/grafana-apiserver/auth/authorizer/provider.go b/pkg/services/grafana-apiserver/auth/authorizer/provider.go index debff38df73..306b7e4e285 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/provider.go +++ b/pkg/services/grafana-apiserver/auth/authorizer/provider.go @@ -7,16 +7,28 @@ import ( "k8s.io/apiserver/pkg/authorization/union" "github.com/grafana/grafana/pkg/services/grafana-apiserver/auth/authorizer/org" + "github.com/grafana/grafana/pkg/services/grafana-apiserver/auth/authorizer/stack" + "github.com/grafana/grafana/pkg/setting" ) func ProvideAuthorizer( orgIDAuthorizer *org.OrgIDAuthorizer, orgRoleAuthorizer *org.OrgRoleAuthorizer, + stackIDAuthorizer *stack.StackIDAuthorizer, + cfg *setting.Cfg, ) authorizer.Authorizer { authorizers := []authorizer.Authorizer{ authorizerfactory.NewPrivilegedGroups(user.SystemPrivilegedGroup), - orgIDAuthorizer, - orgRoleAuthorizer, } + + // In Hosted grafana, the StackID replaces the orgID as a valid namespace + if cfg.StackID != "" { + authorizers = append(authorizers, stackIDAuthorizer) + } else { + authorizers = append(authorizers, orgIDAuthorizer) + } + + authorizers = append(authorizers, orgRoleAuthorizer) + return union.New(authorizers...) } diff --git a/pkg/services/grafana-apiserver/auth/authorizer/stack/stack_id.go b/pkg/services/grafana-apiserver/auth/authorizer/stack/stack_id.go new file mode 100644 index 00000000000..86b3acb008d --- /dev/null +++ b/pkg/services/grafana-apiserver/auth/authorizer/stack/stack_id.go @@ -0,0 +1,56 @@ +package stack + +import ( + "context" + "fmt" + + "k8s.io/apiserver/pkg/authorization/authorizer" + + "github.com/grafana/grafana/pkg/infra/appcontext" + "github.com/grafana/grafana/pkg/infra/log" + grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/setting" +) + +var _ authorizer.Authorizer = &StackIDAuthorizer{} + +type StackIDAuthorizer struct { + log log.Logger + stackID string +} + +func ProvideStackIDAuthorizer(cfg *setting.Cfg) *StackIDAuthorizer { + return &StackIDAuthorizer{ + log: log.New("grafana-apiserver.authorizer.stackid"), + stackID: cfg.StackID, // this lets a single tenant grafana validate stack id (rather than orgs) + } +} + +func (auth StackIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + signedInUser, err := appcontext.User(ctx) + if err != nil { + return authorizer.DecisionDeny, fmt.Sprintf("error getting signed in user: %v", err), nil + } + + info, err := grafanarequest.ParseNamespace(a.GetNamespace()) + if err != nil { + return authorizer.DecisionDeny, fmt.Sprintf("error reading namespace: %v", err), nil + } + + // No opinion when the namespace is arbitrary + if info.OrgID == -1 { + return authorizer.DecisionNoOpinion, "", nil + } + + if info.StackID != auth.stackID { + return authorizer.DecisionDeny, "wrong stack id is selected", nil + } + if info.OrgID != 1 { + return authorizer.DecisionDeny, "cloud instance requires org 1", nil + } + if signedInUser.OrgID != 1 { + return authorizer.DecisionDeny, "user must be in org 1", nil + } + + return authorizer.DecisionAllow, "", nil +} diff --git a/pkg/services/grafana-apiserver/auth/authorizer/stack/wireset.go b/pkg/services/grafana-apiserver/auth/authorizer/stack/wireset.go new file mode 100644 index 00000000000..fe1a183c79f --- /dev/null +++ b/pkg/services/grafana-apiserver/auth/authorizer/stack/wireset.go @@ -0,0 +1,7 @@ +package stack + +import "github.com/google/wire" + +var WireSet = wire.NewSet( + ProvideStackIDAuthorizer, +) diff --git a/pkg/services/grafana-apiserver/auth/authorizer/wireset.go b/pkg/services/grafana-apiserver/auth/authorizer/wireset.go index 5216c1d57cc..ae2d31200a3 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/wireset.go +++ b/pkg/services/grafana-apiserver/auth/authorizer/wireset.go @@ -4,9 +4,11 @@ import ( "github.com/google/wire" "github.com/grafana/grafana/pkg/services/grafana-apiserver/auth/authorizer/org" + "github.com/grafana/grafana/pkg/services/grafana-apiserver/auth/authorizer/stack" ) var WireSet = wire.NewSet( org.WireSet, + stack.WireSet, ProvideAuthorizer, ) diff --git a/pkg/services/grafana-apiserver/endpoints/request/request.go b/pkg/services/grafana-apiserver/endpoints/request/request.go index df8994bccb6..1c12b0f7893 100644 --- a/pkg/services/grafana-apiserver/endpoints/request/request.go +++ b/pkg/services/grafana-apiserver/endpoints/request/request.go @@ -2,25 +2,58 @@ package request import ( "context" + "fmt" "strconv" + "strings" "k8s.io/apiserver/pkg/endpoints/request" ) -func OrgIDFrom(ctx context.Context) (int64, bool) { - ns := request.NamespaceValue(ctx) - return ParseOrgID(ns) +type NamespaceInfo struct { + // OrgID defined in namespace (1 when using stack ids) + OrgID int64 + + // The cloud stack ID (must match the value in cfg.Settings) + StackID string + + // The original namespace string regardless the input + Value string } -func ParseOrgID(ns string) (int64, bool) { - if len(ns) < 5 || ns[:4] != "org-" { - return 0, false +func NamespaceInfoFrom(ctx context.Context, requireOrgID bool) (NamespaceInfo, error) { + info, err := ParseNamespace(request.NamespaceValue(ctx)) + if err == nil && requireOrgID && info.OrgID < 1 { + return info, fmt.Errorf("expected valid orgId") } - - orgID, err := strconv.Atoi(ns[4:]) - if err != nil { - return 0, false - } - - return int64(orgID), true + return info, err +} + +func ParseNamespace(ns string) (NamespaceInfo, error) { + info := NamespaceInfo{Value: ns, OrgID: -1} + if ns == "default" { + info.OrgID = 1 + return info, nil + } + + if strings.HasPrefix(ns, "org-") { + id, err := strconv.Atoi(ns[4:]) + if id < 1 { + return info, fmt.Errorf("invalid org id") + } + if id == 1 { + return info, fmt.Errorf("use default rather than org-1") + } + info.OrgID = int64(id) + return info, err + } + + if strings.HasPrefix(ns, "stack-") { + info.StackID = ns[6:] + if len(info.StackID) < 2 { + return info, fmt.Errorf("invalid stack id") + } + info.OrgID = 1 + return info, nil + } + return info, nil } diff --git a/pkg/services/grafana-apiserver/endpoints/request/request_test.go b/pkg/services/grafana-apiserver/endpoints/request/request_test.go index 76a81346d4f..d38f9bd6b5f 100644 --- a/pkg/services/grafana-apiserver/endpoints/request/request_test.go +++ b/pkg/services/grafana-apiserver/endpoints/request/request_test.go @@ -1,61 +1,127 @@ package request_test import ( - "context" "testing" - "k8s.io/apiserver/pkg/endpoints/request" - grafanarequest "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" ) -func TestOrgIDFrom(t *testing.T) { +func TestParseNamespace(t *testing.T) { tests := []struct { - name string - ctx context.Context - expected int64 - ok bool + name string + namespace string + expected grafanarequest.NamespaceInfo + expectErr bool }{ { - name: "empty namespace", - ctx: context.Background(), - expected: 0, - ok: false, + name: "empty namespace", + expected: grafanarequest.NamespaceInfo{ + OrgID: -1, + }, }, { - name: "incorrect number of parts", - ctx: request.WithNamespace(context.Background(), "org-123-a"), - expected: 0, - ok: false, + name: "incorrect number of parts", + namespace: "org-123-a", + expectErr: true, + expected: grafanarequest.NamespaceInfo{ + OrgID: -1, + }, }, { - name: "incorrect prefix", - ctx: request.WithNamespace(context.Background(), "abc-123"), - expected: 0, - ok: false, + name: "org id not a number", + namespace: "org-invalid", + expectErr: true, + expected: grafanarequest.NamespaceInfo{ + OrgID: -1, + }, }, { - name: "org id not a number", - ctx: request.WithNamespace(context.Background(), "org-invalid"), - expected: 0, - ok: false, + name: "valid org id", + namespace: "org-123", + expected: grafanarequest.NamespaceInfo{ + OrgID: 123, + }, }, { - name: "valid org id", - ctx: request.WithNamespace(context.Background(), "org-123"), - expected: 123, - ok: true, + name: "org should not be 1 in the namespace", + namespace: "org-1", + expectErr: true, + expected: grafanarequest.NamespaceInfo{ + OrgID: -1, + }, + }, + { + name: "can not be negative", + namespace: "org--5", + expectErr: true, + expected: grafanarequest.NamespaceInfo{ + OrgID: -1, + }, + }, + { + name: "can not be zero", + namespace: "org-0", + expectErr: true, + expected: grafanarequest.NamespaceInfo{ + OrgID: -1, + }, + }, + { + name: "default is org 1", + namespace: "default", + expected: grafanarequest.NamespaceInfo{ + OrgID: 1, + }, + }, + { + name: "valid stack", + namespace: "stack-abcdef", + expected: grafanarequest.NamespaceInfo{ + OrgID: 1, + StackID: "abcdef", + }, + }, + { + name: "invalid stack id", + namespace: "stack-", + expectErr: true, + expected: grafanarequest.NamespaceInfo{ + OrgID: -1, + }, + }, + { + name: "invalid stack id (too short)", + namespace: "stack-1", + expectErr: true, + expected: grafanarequest.NamespaceInfo{ + OrgID: -1, + StackID: "1", + }, + }, + { + name: "other namespace", + namespace: "anything", + expected: grafanarequest.NamespaceInfo{ + OrgID: -1, + Value: "anything", + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - actual, ok := grafanarequest.OrgIDFrom(tt.ctx) - if actual != tt.expected { - t.Errorf("OrgIDFrom() returned %d, expected %d", actual, tt.expected) + info, err := grafanarequest.ParseNamespace(tt.namespace) + if tt.expectErr != (err != nil) { + t.Errorf("ParseNamespace() returned %+v, expected an error", info) } - if ok != tt.ok { - t.Errorf("OrgIDFrom() returned %t, expected %t", ok, tt.ok) + if info.OrgID != tt.expected.OrgID { + t.Errorf("ParseNamespace() [OrgID] returned %d, expected %d", info.OrgID, tt.expected.OrgID) + } + if info.StackID != tt.expected.StackID { + t.Errorf("ParseNamespace() [StackID] returned %s, expected %s", info.StackID, tt.expected.StackID) + } + if info.Value != tt.namespace { + t.Errorf("ParseNamespace() [Value] returned %s, expected %s", info.Value, tt.namespace) } }) } diff --git a/public/app/features/playlist/api.ts b/public/app/features/playlist/api.ts index a24152ada11..a66e542cd83 100644 --- a/public/app/features/playlist/api.ts +++ b/public/app/features/playlist/api.ts @@ -53,8 +53,17 @@ interface K8sPlaylist { } class K8sAPI implements PlaylistAPI { - readonly url = `/apis/playlist.x.grafana.com/v0alpha1/namespaces/org-${contextSrv.user.orgId}/playlists`; - readonly legacy = new LegacyAPI(); // set to null for full CRUD + readonly url: string; + readonly legacy: PlaylistAPI | undefined; + + constructor() { + const ns = contextSrv.user.orgId === 1 ? 'default' : `org-${contextSrv.user.orgId}`; + this.url = `/apis/playlist.x.grafana.com/v0alpha1/namespaces/${ns}/playlists`; + + // When undefined, this will use k8s for all CRUD features + // if (!config.featureToggles.grafanaAPIServerWithExperimentalAPIs) { + this.legacy = new LegacyAPI(); + } async getAllPlaylist(): Promise { const result = await getBackendSrv().get(this.url);