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:
Quentin Bisson
2025-03-21 14:18:53 +01:00
committed by GitHub
co-authored by Mihaly Gyongyosi
parent ea89499209
commit aeca9a80a4
7 changed files with 234 additions and 46 deletions
+3 -1
View File
@@ -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 {
+41 -28
View File
@@ -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)
}
+132 -5
View File
@@ -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,