diff --git a/pkg/services/apiserver/auth/authenticator/authenticator.go b/pkg/services/apiserver/auth/authenticator/authenticator.go new file mode 100644 index 00000000000..a052a055095 --- /dev/null +++ b/pkg/services/apiserver/auth/authenticator/authenticator.go @@ -0,0 +1,30 @@ +package authenticator + +import ( + "net/http" + + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/pkg/authentication/request/union" + "k8s.io/klog/v2" + + "github.com/grafana/grafana/pkg/apimachinery/identity" +) + +func NewAuthenticator(authRequestHandlers ...authenticator.Request) authenticator.Request { + handlers := append([]authenticator.Request{authenticator.RequestFunc(identityAuthenticator)}, authRequestHandlers...) + return union.New(handlers...) +} + +var _ authenticator.RequestFunc = identityAuthenticator + +// identityAuthenticator check if we have any identity set in context. +// If not we delegate authentication to next authenticator in the chain. +func identityAuthenticator(req *http.Request) (*authenticator.Response, bool, error) { + ident, err := identity.GetRequester(req.Context()) + if err != nil { + klog.V(5).Info("no idenitty in context", "err", err) + return nil, false, nil + } + + return &authenticator.Response{User: ident}, true, nil +} diff --git a/pkg/services/apiserver/auth/authenticator/authenticator_test.go b/pkg/services/apiserver/auth/authenticator/authenticator_test.go new file mode 100644 index 00000000000..b2a49f98f1b --- /dev/null +++ b/pkg/services/apiserver/auth/authenticator/authenticator_test.go @@ -0,0 +1,63 @@ +package authenticator + +import ( + "context" + "net/http" + "testing" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/user" + "github.com/stretchr/testify/require" + "k8s.io/apiserver/pkg/authentication/authenticator" +) + +func TestAuthenticator(t *testing.T) { + t.Run("should call next authenticator if identity is not set in context", func(t *testing.T) { + req, err := http.NewRequest("GET", "http://localhost:3000/apis", nil) + require.NoError(t, err) + mockAuthenticator := &mockAuthenticator{} + + auth := NewAuthenticator(mockAuthenticator) + res, ok, err := auth.AuthenticateRequest(req) + require.NoError(t, err) + require.False(t, ok) + require.Nil(t, res) + require.True(t, mockAuthenticator.called) + }) + + t.Run("should authenticate when identity is set in context", func(t *testing.T) { + req, err := http.NewRequest("GET", "http://localhost:3000/apis", nil) + require.NoError(t, err) + + ident := &user.SignedInUser{ + Name: "admin", + UserID: 1, + UserUID: "xyz", + Teams: []int64{1, 2}, + } + + req = req.WithContext(identity.WithRequester(context.Background(), ident)) + mockAuthenticator := &mockAuthenticator{} + auth := NewAuthenticator(mockAuthenticator) + res, ok, err := auth.AuthenticateRequest(req) + require.NoError(t, err) + require.True(t, ok) + require.False(t, mockAuthenticator.called) + + require.Equal(t, ident.GetName(), res.User.GetName()) + require.Equal(t, ident.GetUID(), res.User.GetUID()) + require.Equal(t, []string{"1", "2"}, res.User.GetGroups()) + require.Empty(t, res.User.GetExtra()["id-token"]) + }) +} + +var _ authenticator.Request = (*mockAuthenticator)(nil) + +type mockAuthenticator struct { + called bool +} + +func (a *mockAuthenticator) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error) { + a.called = true + return nil, false, nil +} diff --git a/pkg/services/apiserver/auth/authenticator/provider.go b/pkg/services/apiserver/auth/authenticator/provider.go deleted file mode 100644 index 389c808d528..00000000000 --- a/pkg/services/apiserver/auth/authenticator/provider.go +++ /dev/null @@ -1,11 +0,0 @@ -package authenticator - -import ( - "k8s.io/apiserver/pkg/authentication/authenticator" - "k8s.io/apiserver/pkg/authentication/request/union" -) - -func NewAuthenticator(authRequestHandlers ...authenticator.Request) authenticator.Request { - handlers := append([]authenticator.Request{authenticator.RequestFunc(signedInUserAuthenticator)}, authRequestHandlers...) - return union.New(handlers...) -} diff --git a/pkg/services/apiserver/auth/authenticator/signedinuser.go b/pkg/services/apiserver/auth/authenticator/signedinuser.go deleted file mode 100644 index 32e4a1e8a7a..00000000000 --- a/pkg/services/apiserver/auth/authenticator/signedinuser.go +++ /dev/null @@ -1,24 +0,0 @@ -package authenticator - -import ( - "net/http" - - "github.com/grafana/grafana/pkg/apimachinery/identity" - "k8s.io/apiserver/pkg/authentication/authenticator" - "k8s.io/klog/v2" -) - -var _ authenticator.RequestFunc = signedInUserAuthenticator - -func signedInUserAuthenticator(req *http.Request) (*authenticator.Response, bool, error) { - ctx := req.Context() - signedInUser, err := identity.GetRequester(ctx) - if err != nil { - klog.V(5).Info("failed to get signed in user", "err", err) - return nil, false, nil - } - - return &authenticator.Response{ - User: signedInUser, - }, true, nil -} diff --git a/pkg/services/apiserver/auth/authenticator/signedinuser_test.go b/pkg/services/apiserver/auth/authenticator/signedinuser_test.go deleted file mode 100644 index 872958f6ce6..00000000000 --- a/pkg/services/apiserver/auth/authenticator/signedinuser_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package authenticator - -import ( - "context" - "net/http" - "testing" - - "github.com/google/uuid" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/user" - "github.com/stretchr/testify/require" - "k8s.io/apiserver/pkg/authentication/authenticator" - "k8s.io/apiserver/pkg/authentication/request/union" -) - -func TestSignedInUser(t *testing.T) { - t.Run("should call next authenticator if SignedInUser is not set", func(t *testing.T) { - req, err := http.NewRequest("GET", "http://localhost:3000/apis", nil) - require.NoError(t, err) - mockAuthenticator := &mockAuthenticator{} - all := union.New(authenticator.RequestFunc(signedInUserAuthenticator), mockAuthenticator) - res, ok, err := all.AuthenticateRequest(req) - require.NoError(t, err) - require.False(t, ok) - require.Nil(t, res) - require.True(t, mockAuthenticator.called) - }) - - t.Run("should set user and group", func(t *testing.T) { - u := &user.SignedInUser{ - Name: "admin", - UserID: 1, - UserUID: "xyz", - Teams: []int64{1, 2}, - } - ctx := identity.WithRequester(context.Background(), u) - req, err := http.NewRequest("GET", "http://localhost:3000/apis", nil) - require.NoError(t, err) - req = req.WithContext(ctx) - mockAuthenticator := &mockAuthenticator{} - all := union.New(authenticator.RequestFunc(signedInUserAuthenticator), mockAuthenticator) - res, ok, err := all.AuthenticateRequest(req) - require.NoError(t, err) - require.True(t, ok) - require.False(t, mockAuthenticator.called) - - require.Equal(t, u.GetName(), res.User.GetName()) - require.Equal(t, u.GetUID(), res.User.GetUID()) - require.Equal(t, []string{"1", "2"}, res.User.GetGroups()) - require.Empty(t, res.User.GetExtra()["id-token"]) - }) - - t.Run("should set ID token when available", func(t *testing.T) { - u := &user.SignedInUser{ - Name: "admin", - UserID: 1, - UserUID: uuid.New().String(), - Teams: []int64{1, 2}, - IDToken: "test-id-token", - } - ctx := identity.WithRequester(context.Background(), u) - req, err := http.NewRequest("GET", "http://localhost:3000/apis", nil) - require.NoError(t, err) - req = req.WithContext(ctx) - mockAuthenticator := &mockAuthenticator{} - all := union.New(authenticator.RequestFunc(signedInUserAuthenticator), mockAuthenticator) - res, ok, err := all.AuthenticateRequest(req) - require.NoError(t, err) - require.True(t, ok) - - require.False(t, mockAuthenticator.called) - require.Equal(t, u.GetName(), res.User.GetName()) - require.Equal(t, u.GetUID(), res.User.GetUID()) - require.Equal(t, []string{"1", "2"}, res.User.GetGroups()) - require.Equal(t, "test-id-token", res.User.GetExtra()["id-token"][0]) - }) -} - -var _ authenticator.Request = (*mockAuthenticator)(nil) - -type mockAuthenticator struct { - called bool -} - -func (a *mockAuthenticator) AuthenticateRequest(req *http.Request) (*authenticator.Response, bool, error) { - a.called = true - return nil, false, nil -} diff --git a/pkg/services/apiserver/auth/authorizer/provider.go b/pkg/services/apiserver/auth/authorizer/authorizer.go similarity index 67% rename from pkg/services/apiserver/auth/authorizer/provider.go rename to pkg/services/apiserver/auth/authorizer/authorizer.go index 134855c996e..b41849df347 100644 --- a/pkg/services/apiserver/auth/authorizer/provider.go +++ b/pkg/services/apiserver/auth/authorizer/authorizer.go @@ -3,7 +3,6 @@ package authorizer import ( "context" - orgsvc "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/setting" "k8s.io/apimachinery/pkg/runtime/schema" k8suser "k8s.io/apiserver/pkg/authentication/user" @@ -19,17 +18,21 @@ type GrafanaAuthorizer struct { auth authorizer.Authorizer } -func NewGrafanaAuthorizer(cfg *setting.Cfg, orgService orgsvc.Service) *GrafanaAuthorizer { +// NewGrafanaAuthorizer returns an authorizer configured for a grafana instance. +// This authorizer is a chain of smaller authorizers that together form the decision if +// access should be granted. +// 1. We deny all impersonate request. +// 2. We allow all identities that belongs to `system:masters` group, regular grafana identities cannot +// be part of this group +// 3. We check that identity is allowed to make a request for namespace. +// 4. We check authorizer that is configured speficially for an api. +// 5. As a last fallback we check Role, this will only happen if an api have not configured +// an authorizer or return authorizer.DecisionNoOpinion +func NewGrafanaAuthorizer(cfg *setting.Cfg) *GrafanaAuthorizer { authorizers := []authorizer.Authorizer{ - &impersonationAuthorizer{}, + newImpersonationAuthorizer(), authorizerfactory.NewPrivilegedGroups(k8suser.SystemPrivilegedGroup), - } - - // In Hosted grafana, the StackID replaces the orgID as a valid namespace - if cfg.StackID != "" { - authorizers = append(authorizers, newStackIDAuthorizer(cfg)) - } else { - authorizers = append(authorizers, newOrgIDAuthorizer(orgService)) + newNamespaceAuthorizer(), } // Individual services may have explicit implementations @@ -38,7 +41,7 @@ func NewGrafanaAuthorizer(cfg *setting.Cfg, orgService orgsvc.Service) *GrafanaA // org role is last -- and will return allow for verbs that match expectations // The apiVersion flavors will run first and can return early when FGAC has appropriate rules - authorizers = append(authorizers, newOrgRoleAuthorizer(orgService)) + authorizers = append(authorizers, newRoleAuthorizer()) return &GrafanaAuthorizer{ apis: apis, auth: union.New(authorizers...), diff --git a/pkg/services/apiserver/auth/authorizer/impersonation.go b/pkg/services/apiserver/auth/authorizer/impersonation.go index f01cc825013..c736173bd5f 100644 --- a/pkg/services/apiserver/auth/authorizer/impersonation.go +++ b/pkg/services/apiserver/auth/authorizer/impersonation.go @@ -8,6 +8,10 @@ import ( var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil) +func newImpersonationAuthorizer() *impersonationAuthorizer { + return &impersonationAuthorizer{} +} + // ImpersonationAuthorizer denies all impersonation requests. type impersonationAuthorizer struct{} diff --git a/pkg/services/apiserver/auth/authorizer/namespace.go b/pkg/services/apiserver/auth/authorizer/namespace.go new file mode 100644 index 00000000000..45a792d084f --- /dev/null +++ b/pkg/services/apiserver/auth/authorizer/namespace.go @@ -0,0 +1,52 @@ +package authorizer + +import ( + "context" + "fmt" + + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "k8s.io/apiserver/pkg/authorization/authorizer" +) + +type namespaceAuthorizer struct { +} + +func newNamespaceAuthorizer() *namespaceAuthorizer { + return &namespaceAuthorizer{} +} + +func (auth namespaceAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { + ident, err := identity.GetRequester(ctx) + if err != nil { + return authorizer.DecisionDeny, "missing auth info", fmt.Errorf("missing auth info: %w", err) + } + + if ident.GetIsGrafanaAdmin() { + return authorizer.DecisionNoOpinion, "", nil + } + + if !a.IsResourceRequest() { + return authorizer.DecisionNoOpinion, "", nil + } + + // If we have an anonymous user, let the next authorizers decide. + if types.IsIdentityType(ident.GetIdentityType(), types.TypeAnonymous) { + return authorizer.DecisionNoOpinion, "", nil + } + + ns, err := types.ParseNamespace(a.GetNamespace()) + if err != nil { + return authorizer.DecisionDeny, "invalid namespace", err + } + + if ns.OrgID != ident.GetOrgID() { + return authorizer.DecisionDeny, "invalid org", nil + } + + if !types.NamespaceMatches(ident.GetNamespace(), a.GetNamespace()) { + return authorizer.DecisionDeny, "invalid namespace", nil + } + + return authorizer.DecisionNoOpinion, "", nil +} diff --git a/pkg/services/apiserver/auth/authorizer/org_id.go b/pkg/services/apiserver/auth/authorizer/org_id.go deleted file mode 100644 index a76f55e73d7..00000000000 --- a/pkg/services/apiserver/auth/authorizer/org_id.go +++ /dev/null @@ -1,87 +0,0 @@ -package authorizer - -import ( - "context" - "fmt" - - "k8s.io/apiserver/pkg/authorization/authorizer" - - claims "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/services/org" -) - -var _ authorizer.Authorizer = &orgIDAuthorizer{} - -type orgIDAuthorizer struct { - log log.Logger - org org.Service -} - -func newOrgIDAuthorizer(orgService org.Service) *orgIDAuthorizer { - 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) { - signedInUser, err := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, fmt.Sprintf("error getting signed in user: %v", err), nil - } - - info, err := claims.ParseNamespace(a.GetNamespace()) - if err != nil { - return authorizer.DecisionDeny, fmt.Sprintf("error reading namespace: %v", err), nil - } - - // No opinion when the namespace is empty - if info.Value == "" { - return authorizer.DecisionNoOpinion, "", nil - } - - // Grafana super admins can see things in every org - if signedInUser.GetIsGrafanaAdmin() { - return authorizer.DecisionNoOpinion, "", nil - } - - if info.OrgID == -1 { - return authorizer.DecisionDeny, "org id is required", nil - } - - if info.StackID != 0 { - 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.GetOrgID() == info.OrgID { - return authorizer.DecisionNoOpinion, "", nil - } - - // If we have an anonymous user, let the next authorizers decide. - if signedInUser.GetIdentityType() == claims.TypeAnonymous { - return authorizer.DecisionNoOpinion, "", nil - } - - // Check if the user has access to the specified org - // nolint:staticcheck - userId, err := signedInUser.GetInternalID() - if err != nil { - return authorizer.DecisionDeny, "unable to get userId", err - } - query := org.GetUserOrgListQuery{UserID: userId} - result, err := auth.org.GetUserOrgList(ctx, &query) - if err != nil { - return authorizer.DecisionDeny, "error getting user org list", err - } - - for _, org := range result { - if org.OrgID == info.OrgID { - return authorizer.DecisionNoOpinion, "", nil - } - } - - return authorizer.DecisionDeny, fmt.Sprintf("user %d is not a member of org %d", userId, info.OrgID), nil -} diff --git a/pkg/services/apiserver/auth/authorizer/org_role.go b/pkg/services/apiserver/auth/authorizer/role.go similarity index 74% rename from pkg/services/apiserver/auth/authorizer/org_role.go rename to pkg/services/apiserver/auth/authorizer/role.go index 017fb0c7b9b..6bc6e9d5474 100644 --- a/pkg/services/apiserver/auth/authorizer/org_role.go +++ b/pkg/services/apiserver/auth/authorizer/role.go @@ -5,22 +5,19 @@ import ( "fmt" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/org" "k8s.io/apiserver/pkg/authorization/authorizer" ) -var _ authorizer.Authorizer = &orgRoleAuthorizer{} +var _ authorizer.Authorizer = &roleAuthorizer{} -type orgRoleAuthorizer struct { - log log.Logger +type roleAuthorizer struct{} + +func newRoleAuthorizer() *roleAuthorizer { + return &roleAuthorizer{} } -func newOrgRoleAuthorizer(orgService org.Service) *orgRoleAuthorizer { - 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) { +func (auth roleAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) { signedInUser, err := identity.GetRequester(ctx) if err != nil { return authorizer.DecisionDeny, fmt.Sprintf("error getting signed in user: %v", err), nil diff --git a/pkg/services/apiserver/auth/authorizer/stack_id.go b/pkg/services/apiserver/auth/authorizer/stack_id.go deleted file mode 100644 index 2232335984d..00000000000 --- a/pkg/services/apiserver/auth/authorizer/stack_id.go +++ /dev/null @@ -1,66 +0,0 @@ -package authorizer - -import ( - "context" - "fmt" - "strconv" - - "k8s.io/apiserver/pkg/authorization/authorizer" - - claims "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/setting" -) - -var _ authorizer.Authorizer = &stackIDAuthorizer{} - -type stackIDAuthorizer struct { - log log.Logger - stackID int64 -} - -func newStackIDAuthorizer(cfg *setting.Cfg) *stackIDAuthorizer { - stackID, err := strconv.ParseInt(cfg.StackID, 10, 64) - if err != nil { - return nil - } - return &stackIDAuthorizer{ - log: log.New("grafana-apiserver.authorizer.stackid"), - stackID: 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 := identity.GetRequester(ctx) - if err != nil { - return authorizer.DecisionDeny, fmt.Sprintf("error getting signed in user: %v", err), nil - } - - // If we have an anonymous user, let the next authorizers decide. - if signedInUser.GetIdentityType() == claims.TypeAnonymous { - return authorizer.DecisionNoOpinion, "", nil - } - - info, err := claims.ParseNamespace(a.GetNamespace()) - if err != nil { - return authorizer.DecisionDeny, fmt.Sprintf("error reading namespace: %v", err), nil - } - - // No opinion when the namespace is empty - if info.Value == "" { - return authorizer.DecisionNoOpinion, "", nil - } - if info.StackID != auth.stackID { - msg := fmt.Sprintf("wrong stack id is selected (expected: %d, found %d)", auth.stackID, info.StackID) - return authorizer.DecisionDeny, msg, nil - } - if info.OrgID != 1 { - return authorizer.DecisionDeny, "cloud instance requires org 1", nil - } - if signedInUser.GetOrgID() != 1 { - return authorizer.DecisionDeny, "user must be in org 1", nil - } - - return authorizer.DecisionNoOpinion, "", nil -} diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 4a4b5ac3ab0..e37ac50fda9 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -46,7 +46,6 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/utils" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" @@ -158,7 +157,6 @@ func ProvideService( cfg *setting.Cfg, features featuremgmt.FeatureToggles, rr routing.RouteRegister, - orgService org.Service, tracing *tracing.TracingService, serverLockService *serverlock.ServerLockService, db db.DB, @@ -178,7 +176,7 @@ func ProvideService( rr: rr, stopCh: make(chan struct{}), builders: []builder.APIGroupBuilder{}, - authorizer: authorizer.NewGrafanaAuthorizer(cfg, orgService), + authorizer: authorizer.NewGrafanaAuthorizer(cfg), tracing: tracing, db: db, // For Unified storage metrics: metrics.ProvideRegisterer(), diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go index 43ede8bbc57..4c47c6945bd 100644 --- a/pkg/services/authn/authnimpl/service.go +++ b/pkg/services/authn/authnimpl/service.go @@ -12,7 +12,7 @@ import ( "go.opentelemetry.io/otel/codes" "go.opentelemetry.io/otel/trace" - claims "github.com/grafana/authlib/types" + "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/apimachinery/identity" @@ -34,6 +34,7 @@ const ( ) var ( + errInvalidNamespace = errutil.Forbidden("authn.invalid-namespace", errutil.WithPublicMessage("invalid namespace")) errCantAuthenticateReq = errutil.Unauthorized("auth.unauthorized") errDisabledIdentity = errutil.Unauthorized("identity.disabled") ) @@ -57,9 +58,12 @@ func ProvideService( cfg *setting.Cfg, tracer tracing.Tracer, sessionService auth.UserTokenService, usageStats usagestats.Service, registerer prometheus.Registerer, authTokenService login.AuthInfoService, ) *Service { + stackID, _ := strconv.ParseInt(cfg.StackID, 10, 64) + s := &Service{ log: log.New("authn.service"), cfg: cfg, + stackID: stackID, clients: make(map[string]authn.Client), clientQueue: newQueue[authn.ContextAwareClient](), idenityResolverClients: make(map[string]authn.IdentityResolverClient), @@ -77,8 +81,9 @@ func ProvideService( } type Service struct { - log log.Logger - cfg *setting.Cfg + log log.Logger + cfg *setting.Cfg + stackID int64 clients map[string]authn.Client clientQueue *queue[authn.ContextAwareClient] @@ -103,7 +108,11 @@ func (s *Service) Authenticate(ctx context.Context, r *authn.Request) (*authn.Id ctx, span := s.tracer.Start(ctx, "authn.Authenticate") defer span.End() - r.OrgID = orgIDFromRequest(r) + orgID, err := s.orgIDFromRequest(r) + if err != nil { + return nil, err + } + r.OrgID = orgID var authErr error for _, item := range s.clientQueue.items { @@ -204,7 +213,11 @@ func (s *Service) Login(ctx context.Context, client string, r *authn.Request) (i )) defer span.End() - r.OrgID = orgIDFromRequest(r) + orgID, err := s.orgIDFromRequest(r) + if err != nil { + return nil, err + } + r.OrgID = orgID defer func() { for _, hook := range s.postLoginHooks.items { @@ -226,7 +239,7 @@ func (s *Service) Login(ctx context.Context, client string, r *authn.Request) (i } // Login is only supported for users - if !id.IsIdentityType(claims.TypeUser) { + if !id.IsIdentityType(types.TypeUser) { s.metrics.failedLogin.WithLabelValues(client).Inc() return nil, authn.ErrUnsupportedIdentity.Errorf("expected identity of type user but got: %s", id.GetIdentityType()) } @@ -292,7 +305,7 @@ func (s *Service) Logout(ctx context.Context, user identity.Requester, sessionTo redirect.URL = s.cfg.SignoutRedirectUrl } - if !user.IsIdentityType(claims.TypeUser) { + if !user.IsIdentityType(types.TypeUser) { return redirect, nil } @@ -350,7 +363,7 @@ func (s *Service) ResolveIdentity(ctx context.Context, orgID int64, typedID stri identity, err := s.resolveIdenity(ctx, orgID, typedID) if err != nil { - if errors.Is(err, claims.ErrInvalidTypedID) { + if errors.Is(err, types.ErrInvalidTypedID) { return nil, authn.ErrUnsupportedIdentity.Errorf("invalid identity type") } @@ -409,16 +422,16 @@ func (s *Service) resolveIdenity(ctx context.Context, orgID int64, typedID strin ctx, span := s.tracer.Start(ctx, "authn.resolveIdentity") defer span.End() - t, i, err := claims.ParseTypeID(typedID) + t, i, err := types.ParseTypeID(typedID) if err != nil { return nil, err } - if claims.IsIdentityType(t, claims.TypeUser) { + if types.IsIdentityType(t, types.TypeUser) { return &authn.Identity{ OrgID: orgID, ID: i, - Type: claims.TypeUser, + Type: types.TypeUser, ClientParams: authn.ClientParams{ AllowGlobalOrg: true, FetchSyncedUser: true, @@ -427,10 +440,10 @@ func (s *Service) resolveIdenity(ctx context.Context, orgID int64, typedID strin }, nil } - if claims.IsIdentityType(t, claims.TypeServiceAccount) { + if types.IsIdentityType(t, types.TypeServiceAccount) { return &authn.Identity{ ID: i, - Type: claims.TypeServiceAccount, + Type: types.TypeServiceAccount, OrgID: orgID, ClientParams: authn.ClientParams{ AllowGlobalOrg: true, @@ -462,17 +475,66 @@ func (s *Service) errorLogFunc(ctx context.Context, err error) func(msg string, return l.Warn } -func orgIDFromRequest(r *authn.Request) int64 { +func (s *Service) orgIDFromRequest(r *authn.Request) (int64, error) { if r.HTTPRequest == nil { - return 0 + return 0, nil + } + + orgID, err := s.orgIDFromNamespace(r.HTTPRequest) + if err != nil { + return 0, err } - orgID := orgIDFromQuery(r.HTTPRequest) if orgID > 0 { - return orgID + return orgID, nil } - return orgIDFromHeader(r.HTTPRequest) + orgID = orgIDFromQuery(r.HTTPRequest) + if orgID > 0 { + return orgID, nil + } + + return orgIDFromHeader(r.HTTPRequest), nil +} + +func (s *Service) orgIDFromNamespace(req *http.Request) (int64, error) { + if !strings.HasPrefix(req.URL.Path, "/apis") { + return 0, nil + } + + namespace := parseNamespace(req.URL.Path) + if namespace == "" { + return 0, nil + } + + info, err := types.ParseNamespace(namespace) + if err != nil { + return 0, nil + } + + if info.StackID != s.stackID { + return 0, errInvalidNamespace.Errorf("invalid namespace") + } + + return info.OrgID, nil +} + +func parseNamespace(path string) string { + // Pretty navie parsing of namespace but it should do the job + // Possbile url paths can be found here: + // https://github.com/kubernetes/kubernetes/blob/803e9d64952407981b3815b1d749cc96a39ba3c6/staging/src/k8s.io/apiserver/pkg/endpoints/request/requestinfo.go#L104-L127 + const namespacePath = "/namespaces/" + index := strings.Index(path, namespacePath) + if index == -1 { + return "" + } + + parts := strings.Split(path[index+len(namespacePath):], "/") + if len(parts) == 0 { + return "" + } + + return parts[0] } // name of query string used to target specific org for request diff --git a/pkg/services/authn/authnimpl/service_test.go b/pkg/services/authn/authnimpl/service_test.go index 8e5a97d317d..3decba02de4 100644 --- a/pkg/services/authn/authnimpl/service_test.go +++ b/pkg/services/authn/authnimpl/service_test.go @@ -265,7 +265,9 @@ func TestService_OrgID(t *testing.T) { type TestCase struct { desc string req *authn.Request + stackID int64 expectedOrgID int64 + expectedErr error } tests := []TestCase{ @@ -301,12 +303,48 @@ func TestService_OrgID(t *testing.T) { }}, expectedOrgID: 0, }, + { + desc: "should set org id from default namespace", + req: &authn.Request{HTTPRequest: &http.Request{ + Header: map[string][]string{}, + URL: mustParseURL("http://localhost/apis/folder.grafana.app/v0alpha1/namespaces/default/folders"), + }}, + expectedOrgID: 1, + }, + { + desc: "should set org id from namespace", + req: &authn.Request{HTTPRequest: &http.Request{ + Header: map[string][]string{}, + URL: mustParseURL("http://localhost/apis/folder.grafana.app/v0alpha1/namespaces/org-2/folders"), + }}, + expectedOrgID: 2, + }, + { + desc: "should set set org 1 for stack namespace", + req: &authn.Request{HTTPRequest: &http.Request{ + Header: map[string][]string{}, + URL: mustParseURL("http://localhost/apis/folder.grafana.app/v0alpha1/namespaces/stacks-100/folders"), + }}, + stackID: 100, + expectedOrgID: 1, + }, + { + desc: "should error for wrong stack namespace", + req: &authn.Request{HTTPRequest: &http.Request{ + Header: map[string][]string{}, + URL: mustParseURL("http://localhost/apis/folder.grafana.app/v0alpha1/namespaces/stacks-100/folders"), + }}, + stackID: 101, + expectedOrgID: 0, + expectedErr: errInvalidNamespace, + }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { var calledWith int64 s := setupTests(t, func(svc *Service) { + svc.stackID = tt.stackID svc.RegisterClient(authntest.MockClient{ AuthenticateFunc: func(ctx context.Context, r *authn.Request) (*authn.Identity, error) { calledWith = r.OrgID @@ -316,7 +354,8 @@ func TestService_OrgID(t *testing.T) { }) }) - _, _ = s.Authenticate(context.Background(), tt.req) + _, err := s.Authenticate(context.Background(), tt.req) + assert.ErrorIs(t, tt.expectedErr, err) assert.Equal(t, tt.expectedOrgID, calledWith) }) } diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go index 453cd813b31..13880d44dc6 100644 --- a/pkg/tests/apis/helper.go +++ b/pkg/tests/apis/helper.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + goerrors "errors" "fmt" "io" "net/http" @@ -49,7 +50,10 @@ import ( "github.com/grafana/grafana/pkg/tests/testinfra" ) -const Org1 = "Org1" +const ( + Org1 = "Org1" + Org2 = "OrgB" +) type K8sTestHelper struct { t *testing.T @@ -61,6 +65,10 @@ type K8sTestHelper struct { // // Registered groups groups []metav1.APIGroup + + orgSvc org.Service + teamSvc team.Service + userSvc user.Service } func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { @@ -85,8 +93,27 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper { Namespacer: request.GetNamespaceMapper(nil), } + quotaService := quotaimpl.ProvideService(c.env.SQLStore, c.env.Cfg) + orgSvc, err := orgimpl.ProvideService(c.env.SQLStore, c.env.Cfg, quotaService) + require.NoError(c.t, err) + c.orgSvc = orgSvc + + teamSvc, err := teamimpl.ProvideService(c.env.SQLStore, c.env.Cfg, tracing.NewNoopTracerService()) + require.NoError(c.t, err) + c.teamSvc = teamSvc + + userSvc, err := userimpl.ProvideService( + c.env.SQLStore, orgSvc, c.env.Cfg, teamSvc, + localcache.ProvideService(), tracing.NewNoopTracerService(), quotaService, + supportbundlestest.NewFakeBundleService()) + require.NoError(c.t, err) + c.userSvc = userSvc + + _ = c.CreateOrg(Org1) + _ = c.CreateOrg(Org2) + c.Org1 = c.createTestUsers(Org1) - c.OrgB = c.createTestUsers("OrgB") + c.OrgB = c.createTestUsers(Org2) c.loadAPIGroups() @@ -455,6 +482,7 @@ func (c *K8sTestHelper) createTestUsers(orgName string) OrgUsers { Editor: c.CreateUser("editor", orgName, org.RoleEditor, nil), Viewer: c.CreateUser("viewer", orgName, org.RoleViewer, nil), } + users.Staff = c.CreateTeam("staff", "staff@"+orgName, users.Admin.Identity.GetOrgID()) // Add Admin and Editor to Staff team as Admin and Member, respectively. @@ -464,61 +492,67 @@ func (c *K8sTestHelper) createTestUsers(orgName string) OrgUsers { return users } +func (c *K8sTestHelper) CreateOrg(name string) int64 { + if name == Org1 { + return 1 + } + + oldAssing := c.env.Cfg.AutoAssignOrg + defer func() { + c.env.Cfg.AutoAssignOrg = oldAssing + }() + + c.env.Cfg.AutoAssignOrg = false + o, err := c.orgSvc.GetByName(context.Background(), &org.GetOrgByNameQuery{ + Name: name, + }) + if goerrors.Is(err, org.ErrOrgNotFound) { + id, err := c.orgSvc.GetOrCreate(context.Background(), name) + require.NoError(c.t, err) + return id + } + + require.NoError(c.t, err) + return o.ID +} + func (c *K8sTestHelper) CreateUser(name string, orgName string, basicRole org.RoleType, permissions []resourcepermissions.SetResourcePermissionCommand) User { c.t.Helper() - store := c.env.SQLStore - defer func() { - c.env.Cfg.AutoAssignOrg = false - c.env.Cfg.AutoAssignOrgId = 1 // the default - }() - - quotaService := quotaimpl.ProvideService(store, c.env.Cfg) - - orgService, err := orgimpl.ProvideService(store, c.env.Cfg, quotaService) - require.NoError(c.t, err) - - orgId := int64(1) - if orgName != Org1 { - o, err := orgService.GetByName(context.Background(), &org.GetOrgByNameQuery{Name: orgName}) - if err != nil { - if !org.ErrOrgNotFound.Is(err) { - require.NoError(c.t, err) - } - orgId, err = orgService.GetOrCreate(context.Background(), orgName) - require.NoError(c.t, err) - } else { - orgId = o.ID - } - } - c.env.Cfg.AutoAssignOrg = true - c.env.Cfg.AutoAssignOrgId = int(orgId) - - teamSvc, err := teamimpl.ProvideService(store, c.env.Cfg, tracing.InitializeTracerForTest()) - require.NoError(c.t, err) - - cache := localcache.ProvideService() - userSvc, err := userimpl.ProvideService( - store, orgService, c.env.Cfg, teamSvc, - cache, tracing.InitializeTracerForTest(), quotaService, - supportbundlestest.NewFakeBundleService()) - require.NoError(c.t, err) + orgId := c.CreateOrg(orgName) baseUrl := fmt.Sprintf("http://%s", c.env.Server.HTTPServer.Listener.Addr()) - u, err := userSvc.Create(context.Background(), &user.CreateUserCommand{ + // make org1 admins grafana admins + isGrafanaAdmin := basicRole == identity.RoleAdmin && orgId == 1 + + u, err := c.userSvc.Create(context.Background(), &user.CreateUserCommand{ DefaultOrgRole: string(basicRole), Password: user.Password(name), Login: fmt.Sprintf("%s-%d", name, orgId), OrgID: orgId, - IsAdmin: basicRole == identity.RoleAdmin && orgId == 1, // make org1 admins grafana admins + IsAdmin: isGrafanaAdmin, }) + + // for tests to work we need to add grafana admins to every org + if isGrafanaAdmin { + orgs, err := c.orgSvc.Search(context.Background(), &org.SearchOrgsQuery{}) + require.NoError(c.t, err) + for _, o := range orgs { + _ = c.orgSvc.AddOrgUser(context.Background(), &org.AddOrgUserCommand{ + Role: identity.RoleAdmin, + OrgID: o.ID, + UserID: u.ID, + }) + } + } + require.NoError(c.t, err) require.Equal(c.t, orgId, u.OrgID) require.True(c.t, u.ID > 0) // should this always return a user with ID token? - s, err := userSvc.GetSignedInUser(context.Background(), &user.GetSignedInUserQuery{ + s, err := c.userSvc.GetSignedInUser(context.Background(), &user.GetSignedInUserQuery{ UserID: u.ID, Login: u.Login, Email: u.Email, @@ -563,19 +597,6 @@ func (c *K8sTestHelper) SetPermissions(user User, permissions []resourcepermissi } func (c *K8sTestHelper) AddOrUpdateTeamMember(user User, teamID int64, permission team.PermissionType) { - teamSvc, err := teamimpl.ProvideService(c.env.SQLStore, c.env.Cfg, tracing.InitializeTracerForTest()) - require.NoError(c.t, err) - - orgService, err := orgimpl.ProvideService(c.env.SQLStore, c.env.Cfg, c.env.Server.HTTPServer.QuotaService) - require.NoError(c.t, err) - - cache := localcache.ProvideService() - userSvc, err := userimpl.ProvideService( - c.env.SQLStore, orgService, c.env.Cfg, teamSvc, - cache, tracing.InitializeTracerForTest(), c.env.Server.HTTPServer.QuotaService, - supportbundlestest.NewFakeBundleService()) - require.NoError(c.t, err) - teampermissionSvc, err := ossaccesscontrol.ProvideTeamPermissions( c.env.Cfg, c.env.FeatureToggles, @@ -584,8 +605,8 @@ func (c *K8sTestHelper) AddOrUpdateTeamMember(user User, teamID int64, permissio c.env.Server.HTTPServer.AccessControl, c.env.Server.HTTPServer.License, c.env.Server.HTTPServer.AlertNG.AccesscontrolService, - teamSvc, - userSvc, + c.teamSvc, + c.userSvc, resourcepermissions.NewActionSetService(c.env.FeatureToggles), ) require.NoError(c.t, err) @@ -662,7 +683,7 @@ func (c *K8sTestHelper) CreateDS(cmd *datasources.AddDataSourceCommand) *datasou func (c *K8sTestHelper) CreateTeam(name, email string, orgID int64) team.Team { c.t.Helper() - team, err := c.env.Server.HTTPServer.TeamService.CreateTeam(context.Background(), name, email, orgID) + team, err := c.teamSvc.CreateTeam(context.Background(), name, email, orgID) require.NoError(c.t, err) return team } diff --git a/pkg/tests/apis/iam/iam_test.go b/pkg/tests/apis/iam/iam_test.go index 65e4bfdf810..f48cbc2855b 100644 --- a/pkg/tests/apis/iam/iam_test.go +++ b/pkg/tests/apis/iam/iam_test.go @@ -110,6 +110,10 @@ func TestIntegrationIdentity(t *testing.T) { // Get just the specs (avoids values that change with each deployment) found = teamClient.SpecJSON(rsp) require.JSONEq(t, `[ + { + "email": "admin-1", + "login": "admin-1" + }, { "email": "admin-3", "login": "admin-3"