mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 12:32:43 +08:00
Apiserver: Refactor authenticator and authorizers (#101449)
* Clean up authenticator * Cleanup authorizers and replace org_id and stack_id with namespace authorizer * Remove dependency on org service * Extract orgID from /apis/ urls and validate stack id
This commit is contained in:
30
pkg/services/apiserver/auth/authenticator/authenticator.go
Normal file
30
pkg/services/apiserver/auth/authenticator/authenticator.go
Normal file
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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...)
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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...),
|
@ -8,6 +8,10 @@ import (
|
||||
|
||||
var _ authorizer.Authorizer = (*impersonationAuthorizer)(nil)
|
||||
|
||||
func newImpersonationAuthorizer() *impersonationAuthorizer {
|
||||
return &impersonationAuthorizer{}
|
||||
}
|
||||
|
||||
// ImpersonationAuthorizer denies all impersonation requests.
|
||||
type impersonationAuthorizer struct{}
|
||||
|
||||
|
52
pkg/services/apiserver/auth/authorizer/namespace.go
Normal file
52
pkg/services/apiserver/auth/authorizer/namespace.go
Normal file
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
@ -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
|
||||
}
|
@ -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(),
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user