K8s: Namespace parsing updates (default + stack-id) (#76310)

Co-authored-by: Todd Treece <360020+toddtreece@users.noreply.github.com>
This commit is contained in:
Ryan McKinley 2023-10-12 11:34:50 -07:00 committed by GitHub
parent be7fe761a3
commit 2a527aa33b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 328 additions and 81 deletions

View File

@ -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,

View File

@ -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))
})
}
}

View File

@ -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 = "<more>" // 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
}

View File

@ -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

View File

@ -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
}

View File

@ -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) {

View File

@ -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...)
}

View File

@ -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
}

View File

@ -0,0 +1,7 @@
package stack
import "github.com/google/wire"
var WireSet = wire.NewSet(
ProvideStackIDAuthorizer,
)

View File

@ -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,
)

View File

@ -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
}

View File

@ -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)
}
})
}

View File

@ -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<Playlist[]> {
const result = await getBackendSrv().get<K8sPlaylistList>(this.url);