JWT: Add org role mapping support to the JWT provider (#101584)
* add org role mapping to the jwt provider * Fix indentation for OrgMapping assignment * add-test * fix linting * add org_attribute_path * fix test * update doc * update doc * Update pkg/services/authn/clients/jwt.go * Update docs --------- Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
co-authored by
Mihaly Gyongyosi
parent
ea89499209
commit
aeca9a80a4
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/infra/remotecache"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
"github.com/grafana/grafana/pkg/login/social/connectors"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
|
||||
"github.com/grafana/grafana/pkg/services/apikey"
|
||||
@@ -112,7 +113,8 @@ func ProvideRegistration(
|
||||
}
|
||||
|
||||
if cfg.JWTAuth.Enabled {
|
||||
authnSvc.RegisterClient(clients.ProvideJWT(jwtService, cfg))
|
||||
orgRoleMapper := connectors.ProvideOrgRoleMapper(cfg, orgService)
|
||||
authnSvc.RegisterClient(clients.ProvideJWT(jwtService, orgRoleMapper, cfg))
|
||||
}
|
||||
|
||||
if cfg.ExtJWTAuth.Enabled {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/login/social/connectors"
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
authJWT "github.com/grafana/grafana/pkg/services/auth/jwt"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
@@ -29,18 +30,22 @@ var (
|
||||
"jwt.invalid_role", errutil.WithPublicMessage("Invalid Role in claim"))
|
||||
)
|
||||
|
||||
func ProvideJWT(jwtService auth.JWTVerifierService, cfg *setting.Cfg) *JWT {
|
||||
func ProvideJWT(jwtService auth.JWTVerifierService, orgRoleMapper *connectors.OrgRoleMapper, cfg *setting.Cfg) *JWT {
|
||||
return &JWT{
|
||||
cfg: cfg,
|
||||
log: log.New(authn.ClientJWT),
|
||||
jwtService: jwtService,
|
||||
cfg: cfg,
|
||||
log: log.New(authn.ClientJWT),
|
||||
jwtService: jwtService,
|
||||
orgRoleMapper: orgRoleMapper,
|
||||
orgMappingCfg: orgRoleMapper.ParseOrgMappingSettings(context.Background(), cfg.JWTAuth.OrgMapping, cfg.JWTAuth.RoleAttributeStrict),
|
||||
}
|
||||
}
|
||||
|
||||
type JWT struct {
|
||||
cfg *setting.Cfg
|
||||
log log.Logger
|
||||
jwtService auth.JWTVerifierService
|
||||
cfg *setting.Cfg
|
||||
orgRoleMapper *connectors.OrgRoleMapper
|
||||
orgMappingCfg connectors.MappingConfiguration
|
||||
log log.Logger
|
||||
jwtService auth.JWTVerifierService
|
||||
}
|
||||
|
||||
func (s *JWT) Name() string {
|
||||
@@ -102,32 +107,31 @@ func (s *JWT) Authenticate(ctx context.Context, r *authn.Request) (*authn.Identi
|
||||
id.Name = name
|
||||
}
|
||||
|
||||
orgRoles, isGrafanaAdmin, err := getRoles(s.cfg, func() (org.RoleType, *bool, error) {
|
||||
if s.cfg.JWTAuth.SkipOrgRoleSync {
|
||||
return "", nil, nil
|
||||
}
|
||||
|
||||
role, grafanaAdmin := s.extractRoleAndAdmin(claims)
|
||||
if s.cfg.JWTAuth.RoleAttributeStrict && !role.IsValid() {
|
||||
return "", nil, errJWTInvalidRole.Errorf("invalid role claim in JWT: %s", role)
|
||||
}
|
||||
|
||||
if !s.cfg.JWTAuth.AllowAssignGrafanaAdmin {
|
||||
return role, nil, nil
|
||||
}
|
||||
|
||||
return role, &grafanaAdmin, nil
|
||||
})
|
||||
id.Groups, err = s.extractGroups(claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id.OrgRoles = orgRoles
|
||||
id.IsGrafanaAdmin = isGrafanaAdmin
|
||||
if !s.cfg.JWTAuth.SkipOrgRoleSync {
|
||||
role, grafanaAdmin := s.extractRoleAndAdmin(claims)
|
||||
if err != nil {
|
||||
s.log.Warn("Failed to extract role", "err", err)
|
||||
}
|
||||
|
||||
id.Groups, err = s.extractGroups(claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if s.cfg.JWTAuth.AllowAssignGrafanaAdmin {
|
||||
id.IsGrafanaAdmin = &grafanaAdmin
|
||||
}
|
||||
|
||||
externalOrgs, err := s.extractOrgs(claims)
|
||||
if err != nil {
|
||||
s.log.Warn("Failed to extract orgs", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id.OrgRoles = s.orgRoleMapper.MapOrgRoles(s.orgMappingCfg, externalOrgs, role)
|
||||
if s.cfg.JWTAuth.RoleAttributeStrict && len(id.OrgRoles) == 0 {
|
||||
return nil, errJWTInvalidRole.Errorf("could not evaluate any valid roles using IdP provided data")
|
||||
}
|
||||
}
|
||||
|
||||
if id.Login == "" && id.Email == "" {
|
||||
@@ -213,3 +217,12 @@ func (s *JWT) extractGroups(claims map[string]any) ([]string, error) {
|
||||
|
||||
return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.GroupsAttributePath, claims)
|
||||
}
|
||||
|
||||
// This code was copied from the social_base.go file and was adapted to match with the JWT structure
|
||||
func (s *JWT) extractOrgs(claims map[string]any) ([]string, error) {
|
||||
if s.cfg.JWTAuth.OrgAttributePath == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
return util.SearchJSONForStringSliceAttr(s.cfg.JWTAuth.OrgAttributePath, claims)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/login/social/connectors"
|
||||
"github.com/grafana/grafana/pkg/services/auth/jwt"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
"github.com/grafana/grafana/pkg/services/login"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/org/orgtest"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@@ -136,6 +139,116 @@ func TestAuthenticateJWT(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Valid Use case with org_mapping",
|
||||
wantID: &authn.Identity{
|
||||
OrgID: 0,
|
||||
OrgName: "",
|
||||
OrgRoles: map[int64]identity.RoleType{4: identity.RoleEditor, 5: identity.RoleViewer},
|
||||
Login: "eai-doe",
|
||||
Groups: []string{"foo", "bar"},
|
||||
Name: "Eai Doe",
|
||||
Email: "eai.doe@cor.po",
|
||||
IsGrafanaAdmin: boolPtr(false),
|
||||
AuthenticatedBy: login.JWTModule,
|
||||
AuthID: "1234567890",
|
||||
IsDisabled: false,
|
||||
HelpFlags1: 0,
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
AllowSignUp: true,
|
||||
FetchSyncedUser: true,
|
||||
SyncOrgRoles: true,
|
||||
SyncPermissions: true,
|
||||
SyncTeams: true,
|
||||
LookUpParams: login.UserLookupParams{
|
||||
Email: stringPtr("eai.doe@cor.po"),
|
||||
Login: stringPtr("eai-doe"),
|
||||
},
|
||||
},
|
||||
},
|
||||
verifyProvider: func(context.Context, string) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"sub": "1234567890",
|
||||
"email": "eai.doe@cor.po",
|
||||
"preferred_username": "eai-doe",
|
||||
"name": "Eai Doe",
|
||||
"roles": "None",
|
||||
"groups": []string{"foo", "bar"},
|
||||
"orgs": []string{"org1", "org2"},
|
||||
}, nil
|
||||
},
|
||||
cfg: &setting.Cfg{
|
||||
JWTAuth: setting.AuthJWTSettings{
|
||||
Enabled: true,
|
||||
HeaderName: jwtHeaderName,
|
||||
EmailClaim: "email",
|
||||
UsernameClaim: "preferred_username",
|
||||
AutoSignUp: true,
|
||||
AllowAssignGrafanaAdmin: true,
|
||||
RoleAttributeStrict: true,
|
||||
RoleAttributePath: "roles",
|
||||
GroupsAttributePath: "groups[]",
|
||||
OrgAttributePath: "orgs[]",
|
||||
OrgMapping: []string{"org1:Org4:Editor", "org2:Org5:Viewer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid Use case with org_mapping and invalid roles",
|
||||
wantID: &authn.Identity{
|
||||
OrgID: 0,
|
||||
OrgName: "",
|
||||
OrgRoles: map[int64]identity.RoleType{4: identity.RoleEditor, 5: identity.RoleViewer},
|
||||
Login: "eai-doe",
|
||||
Groups: []string{"foo", "bar"},
|
||||
Name: "Eai Doe",
|
||||
Email: "eai.doe@cor.po",
|
||||
IsGrafanaAdmin: boolPtr(false),
|
||||
AuthenticatedBy: login.JWTModule,
|
||||
AuthID: "1234567890",
|
||||
IsDisabled: false,
|
||||
HelpFlags1: 0,
|
||||
ClientParams: authn.ClientParams{
|
||||
SyncUser: true,
|
||||
AllowSignUp: true,
|
||||
FetchSyncedUser: true,
|
||||
SyncOrgRoles: true,
|
||||
SyncPermissions: true,
|
||||
SyncTeams: true,
|
||||
LookUpParams: login.UserLookupParams{
|
||||
Email: stringPtr("eai.doe@cor.po"),
|
||||
Login: stringPtr("eai-doe"),
|
||||
},
|
||||
},
|
||||
},
|
||||
verifyProvider: func(context.Context, string) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"sub": "1234567890",
|
||||
"email": "eai.doe@cor.po",
|
||||
"preferred_username": "eai-doe",
|
||||
"name": "Eai Doe",
|
||||
"roles": []string{"Invalid"},
|
||||
"groups": []string{"foo", "bar"},
|
||||
"orgs": []string{"org1", "org2"},
|
||||
}, nil
|
||||
},
|
||||
cfg: &setting.Cfg{
|
||||
JWTAuth: setting.AuthJWTSettings{
|
||||
Enabled: true,
|
||||
HeaderName: jwtHeaderName,
|
||||
EmailClaim: "email",
|
||||
UsernameClaim: "preferred_username",
|
||||
AutoSignUp: true,
|
||||
AllowAssignGrafanaAdmin: true,
|
||||
RoleAttributeStrict: true,
|
||||
RoleAttributePath: "roles",
|
||||
GroupsAttributePath: "groups[]",
|
||||
OrgAttributePath: "orgs[]",
|
||||
OrgMapping: []string{"org1:Org4:Editor", "org2:Org5:Viewer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -146,7 +259,10 @@ func TestAuthenticateJWT(t *testing.T) {
|
||||
VerifyProvider: tc.verifyProvider,
|
||||
}
|
||||
|
||||
jwtClient := ProvideJWT(jwtService, tc.cfg)
|
||||
jwtClient := ProvideJWT(jwtService,
|
||||
connectors.ProvideOrgRoleMapper(tc.cfg,
|
||||
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
|
||||
tc.cfg)
|
||||
validHTTPReq := &http.Request{
|
||||
Header: map[string][]string{
|
||||
jwtHeaderName: {"sample-token"}},
|
||||
@@ -262,7 +378,9 @@ func TestJWTClaimConfig(t *testing.T) {
|
||||
Header: map[string][]string{
|
||||
jwtHeaderName: {token}},
|
||||
}
|
||||
jwtClient := ProvideJWT(jwtService, cfg)
|
||||
jwtClient := ProvideJWT(jwtService, connectors.ProvideOrgRoleMapper(cfg,
|
||||
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
|
||||
cfg)
|
||||
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
|
||||
OrgID: 1,
|
||||
HTTPRequest: httpReq,
|
||||
@@ -372,7 +490,10 @@ func TestJWTTest(t *testing.T) {
|
||||
RoleAttributeStrict: true,
|
||||
},
|
||||
}
|
||||
jwtClient := ProvideJWT(jwtService, cfg)
|
||||
jwtClient := ProvideJWT(jwtService,
|
||||
connectors.ProvideOrgRoleMapper(cfg,
|
||||
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
|
||||
cfg)
|
||||
httpReq := &http.Request{
|
||||
URL: &url.URL{RawQuery: "auth_token=" + tc.token},
|
||||
Header: map[string][]string{
|
||||
@@ -425,7 +546,10 @@ func TestJWTStripParam(t *testing.T) {
|
||||
httpReq := &http.Request{
|
||||
URL: &url.URL{RawQuery: "auth_token=" + token + "&other_param=other_value"},
|
||||
}
|
||||
jwtClient := ProvideJWT(jwtService, cfg)
|
||||
jwtClient := ProvideJWT(jwtService,
|
||||
connectors.ProvideOrgRoleMapper(cfg,
|
||||
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
|
||||
cfg)
|
||||
_, err := jwtClient.Authenticate(context.Background(), &authn.Request{
|
||||
OrgID: 1,
|
||||
HTTPRequest: httpReq,
|
||||
@@ -481,7 +605,10 @@ func TestJWTSubClaimsConfig(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
jwtClient := ProvideJWT(jwtService, cfg)
|
||||
jwtClient := ProvideJWT(jwtService,
|
||||
connectors.ProvideOrgRoleMapper(cfg,
|
||||
&orgtest.FakeOrgService{ExpectedOrgs: []*org.OrgDTO{{ID: 4, Name: "Org4"}, {ID: 5, Name: "Org5"}}}),
|
||||
cfg)
|
||||
identity, err := jwtClient.Authenticate(context.Background(), &authn.Request{
|
||||
OrgID: 1,
|
||||
HTTPRequest: httpReq,
|
||||
|
||||
Reference in New Issue
Block a user