The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/pkg/services/authn/authnimpl/service_test.go

663 lines
21 KiB

package authnimpl
import (
"context"
"errors"
"net/http"
"net/url"
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/codes"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
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/infra/tracing"
"github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/setting"
)
func TestService_Authenticate(t *testing.T) {
type TestCase struct {
desc string
clients []authn.Client
expectedIdentity *authn.Identity
expectedErrors []error
}
var (
firstErr = errors.New("first")
lastErr = errors.New("last")
)
tests := []TestCase{
{
desc: "should succeed with authentication for configured client",
clients: []authn.Client{
&authntest.FakeClient{ExpectedTest: true, ExpectedIdentity: &authn.Identity{ID: "1", Type: claims.TypeUser}},
},
expectedIdentity: &authn.Identity{ID: "1", Type: claims.TypeUser},
},
{
desc: "should succeed with authentication for configured client for identity with fetch permissions params",
clients: []authn.Client{
&authntest.FakeClient{
ExpectedTest: true,
ExpectedIdentity: &authn.Identity{
ID: "2",
Type: claims.TypeUser,
ClientParams: authn.ClientParams{
FetchPermissionsParams: authn.FetchPermissionsParams{
RestrictedActions: []string{
"datasources:read",
"datasources:query",
},
Roles: []string{
"fixed:datasources:reader",
},
},
},
},
},
},
expectedIdentity: &authn.Identity{
ID: "2",
Type: claims.TypeUser,
ClientParams: authn.ClientParams{
FetchPermissionsParams: authn.FetchPermissionsParams{
RestrictedActions: []string{
"datasources:read",
"datasources:query",
},
Roles: []string{
"fixed:datasources:reader",
},
},
},
},
},
{
desc: "should succeed with authentication for client with fetch permissions params made of roles and actions",
clients: []authn.Client{
&authntest.FakeClient{
ExpectedTest: true,
ExpectedIdentity: &authn.Identity{
ID: "2",
Type: claims.TypeUser,
ClientParams: authn.ClientParams{
FetchPermissionsParams: authn.FetchPermissionsParams{
RestrictedActions: []string{
"datasources:read",
"datasources:query",
},
AllowedActions: []string{
"datasources:write",
},
Roles: []string{
"fixed:datasources:writer",
},
},
},
},
},
},
expectedIdentity: &authn.Identity{
ID: "2",
Type: claims.TypeUser,
ClientParams: authn.ClientParams{
FetchPermissionsParams: authn.FetchPermissionsParams{
RestrictedActions: []string{
"datasources:read",
"datasources:query",
},
AllowedActions: []string{
"datasources:write",
},
Roles: []string{
"fixed:datasources:writer",
},
},
},
},
},
{
desc: "should succeed with authentication for second client when first test fail",
clients: []authn.Client{
&authntest.FakeClient{ExpectedName: "1", ExpectedPriority: 1, ExpectedTest: false},
&authntest.FakeClient{
ExpectedName: "2",
ExpectedPriority: 2,
ExpectedTest: true,
ExpectedIdentity: &authn.Identity{ID: "2", Type: claims.TypeUser, AuthID: "service:some-service", AuthenticatedBy: "service_auth"},
},
},
expectedIdentity: &authn.Identity{ID: "2", Type: claims.TypeUser, AuthID: "service:some-service", AuthenticatedBy: "service_auth"},
},
{
desc: "should succeed with authentication for third client when error happened in first",
clients: []authn.Client{
&authntest.FakeClient{ExpectedName: "1", ExpectedPriority: 2, ExpectedTest: false},
&authntest.FakeClient{ExpectedName: "2", ExpectedPriority: 1, ExpectedTest: true, ExpectedErr: errors.New("some error")},
&authntest.FakeClient{ExpectedName: "3", ExpectedPriority: 3, ExpectedTest: true, ExpectedIdentity: &authn.Identity{ID: "3", Type: claims.TypeUser}},
},
expectedIdentity: &authn.Identity{ID: "3", Type: claims.TypeUser},
},
{
desc: "should return error when no client could authenticate the request",
clients: []authn.Client{
&authntest.FakeClient{ExpectedName: "1", ExpectedPriority: 2, ExpectedTest: false},
&authntest.FakeClient{ExpectedName: "2", ExpectedPriority: 1, ExpectedTest: false},
&authntest.FakeClient{ExpectedName: "3", ExpectedPriority: 3, ExpectedTest: false},
},
expectedErrors: []error{errCantAuthenticateReq},
},
{
desc: "should return all errors in chain",
clients: []authn.Client{
&authntest.FakeClient{ExpectedName: "1", ExpectedPriority: 2, ExpectedTest: false},
&authntest.FakeClient{ExpectedName: "2", ExpectedPriority: 1, ExpectedTest: true, ExpectedErr: firstErr},
&authntest.FakeClient{ExpectedName: "3", ExpectedPriority: 3, ExpectedTest: true, ExpectedErr: lastErr},
},
expectedErrors: []error{firstErr, lastErr},
},
{
desc: "should return error on disabled identity",
clients: []authn.Client{
&authntest.FakeClient{ExpectedName: "1", ExpectedTest: true, ExpectedIdentity: &authn.Identity{IsDisabled: true}},
},
expectedErrors: []error{errDisabledIdentity},
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
spanRecorder := tracetest.NewSpanRecorder()
tracer := tracing.InitializeTracerForTest(tracing.WithSpanProcessor(spanRecorder))
svc := setupTests(t, func(svc *Service) {
svc.tracer = tracer
for _, c := range tt.clients {
svc.RegisterClient(c)
}
})
identity, err := svc.Authenticate(context.Background(), &authn.Request{})
spans := spanRecorder.Ended()
if len(tt.expectedErrors) == 0 {
assert.NoError(t, err)
assert.EqualValues(t, tt.expectedIdentity, identity)
matchedClients := make([]*authntest.FakeClient, 0)
for _, client := range tt.clients {
fakeClient, _ := client.(*authntest.FakeClient)
if fakeClient.ExpectedTest {
matchedClients = append(matchedClients, fakeClient)
}
}
require.Len(t, spans, 1+len(matchedClients), "must have spans 1+ number of clients tried")
spansTested := make([]sdktrace.ReadOnlySpan, 0)
for _, span := range spans {
if span.Name() != "authn.Authenticate" {
spansTested = append(spansTested, span)
}
}
assert.Len(t, spansTested, len(matchedClients), "expected spans with name authn.authenticate to match number of clients tested")
// since this is a success case, at least one span should have all 3 attributes
passedAuthnIndex := slices.IndexFunc(spansTested, func(span sdktrace.ReadOnlySpan) bool {
return len(span.Attributes()) >= 3 // more than 3 when there are ClientParams in the identity
})
require.NotEqual(t, -1, passedAuthnIndex, "no spans found all 3 attributes - passed case should have authn attributes set")
passedAuthnSpan := spansTested[passedAuthnIndex]
for _, attr := range passedAuthnSpan.Attributes() {
switch attr.Key {
case "identity.ID":
assert.Equal(t, tt.expectedIdentity.GetID(), attr.Value.AsString())
case "identity.AuthID":
assert.Equal(t, tt.expectedIdentity.AuthID, attr.Value.AsString())
case "identity.AuthenticatedBy":
assert.Equal(t, tt.expectedIdentity.AuthenticatedBy, attr.Value.AsString())
case "identity.ClientParams.FetchPermissionsParams.RestrictedActions":
if len(tt.expectedIdentity.ClientParams.FetchPermissionsParams.RestrictedActions) > 0 {
assert.Equal(t, tt.expectedIdentity.ClientParams.FetchPermissionsParams.RestrictedActions, attr.Value.AsStringSlice())
}
case "identity.ClientParams.FetchPermissionsParams.AllowedActions":
if len(tt.expectedIdentity.ClientParams.FetchPermissionsParams.AllowedActions) > 0 {
assert.Equal(t, tt.expectedIdentity.ClientParams.FetchPermissionsParams.AllowedActions, attr.Value.AsStringSlice())
}
case "identity.ClientParams.FetchPermissionsParams.Roles":
if len(tt.expectedIdentity.ClientParams.FetchPermissionsParams.Roles) > 0 {
assert.Equal(t, tt.expectedIdentity.ClientParams.FetchPermissionsParams.Roles, attr.Value.AsStringSlice())
}
}
}
if len(matchedClients) > 1 {
failedAuthnIndex := slices.IndexFunc(spansTested, func(span sdktrace.ReadOnlySpan) bool {
return span.Status().Code == codes.Error
})
assert.NotEqual(t, -1, failedAuthnIndex, "no spans found for the error case - at least one client in multi client test must have failed")
}
} else {
for _, e := range tt.expectedErrors {
assert.ErrorIs(t, err, e)
}
assert.Nil(t, identity)
}
})
}
}
func TestService_OrgID(t *testing.T) {
type TestCase struct {
desc string
req *authn.Request
stackID int64
expectedOrgID int64
expectedErr error
}
tests := []TestCase{
{
desc: "should set org id when present in header",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{orgIDHeaderName: {"1"}},
URL: &url.URL{},
}},
expectedOrgID: 1,
},
{
desc: "should set org id when present in url",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{},
URL: mustParseURL("http://localhost/?targetOrgId=2"),
}},
expectedOrgID: 2,
},
{
desc: "should prioritise org id from url when present in both header and url",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{orgIDHeaderName: {"1"}},
URL: mustParseURL("http://localhost/?targetOrgId=2"),
}},
expectedOrgID: 2,
},
{
desc: "should set org id to 0 when missing in both header and url",
req: &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{},
URL: &url.URL{},
}},
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
return &authn.Identity{}, nil
},
TestFunc: func(ctx context.Context, r *authn.Request) bool { return true },
})
})
_, err := s.Authenticate(context.Background(), tt.req)
assert.ErrorIs(t, tt.expectedErr, err)
assert.Equal(t, tt.expectedOrgID, calledWith)
})
}
}
func TestService_HookClient(t *testing.T) {
hookCalled := false
s := setupTests(t, func(svc *Service) {
svc.RegisterClient(&authntest.MockClient{
AuthenticateFunc: func(ctx context.Context, r *authn.Request) (*authn.Identity, error) {
return &authn.Identity{}, nil
},
TestFunc: func(ctx context.Context, r *authn.Request) bool {
return true
},
HookFunc: func(ctx context.Context, identity *authn.Identity, r *authn.Request) error {
hookCalled = true
return nil
},
})
})
_, _ = s.Authenticate(context.Background(), &authn.Request{})
require.True(t, hookCalled)
}
func TestService_Login(t *testing.T) {
type TestCase struct {
desc string
client string
expectedClientOK bool
expectedClientErr error
expectedClientIdentity *authn.Identity
expectedSessionErr error
expectedErr error
expectedIdentity *authn.Identity
}
tests := []TestCase{
{
desc: "should login for valid request",
client: "fake",
expectedClientOK: true,
expectedClientIdentity: &authn.Identity{
ID: "1",
Type: claims.TypeUser,
},
expectedIdentity: &authn.Identity{
ID: "1",
Type: claims.TypeUser,
SessionToken: &auth.UserToken{UserId: 1},
},
},
{
desc: "should not login with invalid client",
client: "invalid",
expectedErr: authn.ErrClientNotConfigured,
},
{
desc: "should not login non user identity",
client: "fake",
expectedClientOK: true,
expectedClientIdentity: &authn.Identity{ID: "1", Type: claims.TypeAPIKey},
expectedErr: authn.ErrUnsupportedIdentity,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
s := setupTests(t, func(svc *Service) {
svc.RegisterClient(&authntest.FakeClient{
ExpectedName: "fake",
ExpectedErr: tt.expectedClientErr,
ExpectedTest: tt.expectedClientOK,
ExpectedIdentity: tt.expectedClientIdentity,
})
svc.sessionService = &authtest.FakeUserAuthTokenService{
CreateTokenProvider: func(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error) {
if tt.expectedSessionErr != nil {
return nil, tt.expectedSessionErr
}
return &auth.UserToken{UserId: cmd.User.ID}, nil
},
}
})
identity, err := s.Login(context.Background(), tt.client, &authn.Request{HTTPRequest: &http.Request{
Header: map[string][]string{},
URL: &url.URL{},
}})
assert.ErrorIs(t, err, tt.expectedErr)
assert.EqualValues(t, tt.expectedIdentity, identity)
})
}
}
func TestService_RedirectURL(t *testing.T) {
type testCase struct {
desc string
client string
expectedErr error
}
tests := []testCase{
{
desc: "should generate url for valid redirect client",
client: "redirect",
},
{
desc: "should return error on non existing client",
client: "non-existing",
expectedErr: authn.ErrClientNotConfigured,
},
{
desc: "should return error when client don't support the redirect interface",
client: "non-redirect",
expectedErr: authn.ErrUnsupportedClient,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
service := setupTests(t, func(svc *Service) {
svc.RegisterClient(authntest.FakeRedirectClient{ExpectedName: "redirect"})
svc.RegisterClient(&authntest.FakeClient{ExpectedName: "non-redirect"})
})
_, err := service.RedirectURL(context.Background(), tt.client, nil)
assert.ErrorIs(t, err, tt.expectedErr)
})
}
}
func TestService_Logout(t *testing.T) {
type TestCase struct {
desc string
identity *authn.Identity
sessionToken *usertoken.UserToken
client authn.Client
signoutRedirectURL string
expectedErr error
expectedTokenRevoked bool
expectedRedirect *authn.Redirect
}
tests := []TestCase{
{
desc: "should redirect to default redirect url when identity is not a user",
identity: &authn.Identity{ID: "1", Type: claims.TypeServiceAccount},
expectedRedirect: &authn.Redirect{URL: "http://localhost:3000/login"},
},
{
desc: "should redirect to default redirect url when no external provider was used to authenticate",
identity: &authn.Identity{ID: "1", Type: claims.TypeUser},
expectedRedirect: &authn.Redirect{URL: "http://localhost:3000/login"},
expectedTokenRevoked: true,
},
{
desc: "should redirect to default redirect url when client is not found",
identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: "notfound"},
expectedRedirect: &authn.Redirect{URL: "http://localhost:3000/login"},
expectedTokenRevoked: true,
},
{
desc: "should redirect to default redirect url when client do not implement logout extension",
identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: "azuread"},
expectedRedirect: &authn.Redirect{URL: "http://localhost:3000/login"},
client: &authntest.FakeClient{ExpectedName: "auth.client.azuread"},
expectedTokenRevoked: true,
},
{
desc: "should use signout redirect url if configured",
identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: "azuread"},
expectedRedirect: &authn.Redirect{URL: "some-url"},
client: &authntest.FakeClient{ExpectedName: "auth.client.azuread"},
signoutRedirectURL: "some-url",
expectedTokenRevoked: true,
},
{
desc: "should redirect to client specific url",
identity: &authn.Identity{ID: "1", Type: claims.TypeUser, AuthenticatedBy: "azuread"},
expectedRedirect: &authn.Redirect{URL: "http://idp.com/logout"},
client: &authntest.MockClient{
NameFunc: func() string { return "auth.client.azuread" },
LogoutFunc: func(ctx context.Context, _ identity.Requester, sessionToken *usertoken.UserToken) (*authn.Redirect, bool) {
return &authn.Redirect{URL: "http://idp.com/logout"}, true
},
},
expectedTokenRevoked: true,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
var tokenRevoked bool
s := setupTests(t, func(svc *Service) {
if tt.client != nil {
svc.RegisterClient(tt.client)
}
svc.cfg.AppSubURL = "http://localhost:3000"
svc.sessionService = &authtest.FakeUserAuthTokenService{
RevokeTokenProvider: func(_ context.Context, sessionToken *auth.UserToken, soft bool) error {
tokenRevoked = true
assert.EqualValues(t, tt.sessionToken, sessionToken)
assert.False(t, soft)
return nil
},
}
if tt.signoutRedirectURL != "" {
svc.cfg.SignoutRedirectUrl = tt.signoutRedirectURL
}
})
redirect, err := s.Logout(context.Background(), tt.identity, tt.sessionToken)
assert.ErrorIs(t, err, tt.expectedErr)
assert.EqualValues(t, tt.expectedRedirect, redirect)
assert.Equal(t, tt.expectedTokenRevoked, tokenRevoked)
})
}
}
func TestService_ResolveIdentity(t *testing.T) {
t.Run("should return error for for unknown namespace", func(t *testing.T) {
svc := setupTests(t)
_, err := svc.ResolveIdentity(context.Background(), 1, "some:1")
assert.ErrorIs(t, err, authn.ErrUnsupportedIdentity)
})
t.Run("should return error for for namespace that don't have a resolver", func(t *testing.T) {
svc := setupTests(t)
_, err := svc.ResolveIdentity(context.Background(), 1, "api-key:1")
assert.ErrorIs(t, err, authn.ErrUnsupportedIdentity)
})
t.Run("should resolve for user", func(t *testing.T) {
svc := setupTests(t)
identity, err := svc.ResolveIdentity(context.Background(), 1, "user:1")
assert.NoError(t, err)
assert.NotNil(t, identity)
})
t.Run("should resolve for service account", func(t *testing.T) {
svc := setupTests(t)
identity, err := svc.ResolveIdentity(context.Background(), 1, "service-account:1")
assert.NoError(t, err)
assert.NotNil(t, identity)
})
t.Run("should resolve for valid namespace if client is registered", func(t *testing.T) {
svc := setupTests(t, func(svc *Service) {
svc.RegisterClient(&authntest.MockClient{
IdentityTypeFunc: func() claims.IdentityType { return claims.TypeAPIKey },
ResolveIdentityFunc: func(_ context.Context, _ int64, _ claims.IdentityType, _ string) (*authn.Identity, error) {
return &authn.Identity{}, nil
},
})
})
identity, err := svc.ResolveIdentity(context.Background(), 1, "api-key:1")
assert.NoError(t, err)
assert.NotNil(t, identity)
})
}
func mustParseURL(s string) *url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return u
}
func setupTests(t *testing.T, opts ...func(svc *Service)) *Service {
t.Helper()
s := &Service{
log: log.NewNopLogger(),
cfg: setting.NewCfg(),
clients: make(map[string]authn.Client),
clientQueue: newQueue[authn.ContextAwareClient](),
idenityResolverClients: make(map[string]authn.IdentityResolverClient),
tracer: tracing.InitializeTracerForTest(),
metrics: newMetrics(nil),
postAuthHooks: newQueue[authn.PostAuthHookFn](),
postLoginHooks: newQueue[authn.PostLoginHookFn](),
preLogoutHooks: newQueue[authn.PreLogoutHookFn](),
}
for _, o := range opts {
o(s)
}
return s
}