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
parent ea89499209
commit aeca9a80a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 234 additions and 46 deletions

View File

@ -932,6 +932,8 @@ key_file =
key_id =
role_attribute_path =
role_attribute_strict = false
org_attribute_path =
org_mapping =
groups_attribute_path =
auto_sign_up = false
url_login = false
@ -1543,7 +1545,7 @@ timeout = 10s
# Default data source UID to write to if not specified in the rule definition.
# Only has effect if the grafanaManagedRecordRulesDatasources feature toggle is enabled.
default_datasource_uid =
default_datasource_uid =
# Optional custom headers to include in recording rule write requests.
[recording_rules.custom_headers]

View File

@ -132,7 +132,7 @@
# Set to true or false to enable or disable high availability mode.
# When it's set to false some functions will be simplified and only run in-process
# instead of relying on the database.
#
#
# Only set it to false if you run only a single instance of Grafana.
;high_availability = true
@ -901,6 +901,8 @@
;key_id = some-key-id
;role_attribute_path =
;role_attribute_strict = false
;org_attribute_path =
;org_mapping =
;groups_attribute_path =
;auto_sign_up = false
;url_login = false
@ -1525,7 +1527,7 @@ timeout = 30s
# Default data source UID to write to if not specified in the rule definition.
# Only has effect if the grafanaManagedRecordRulesDatasources feature toggle is enabled.
default_datasource_uid =
default_datasource_uid =
# Optional custom headers to include in recording rule write requests.
[recording_rules.custom_headers]

View File

@ -202,13 +202,19 @@ Grafana checks for the presence of a role using the [JMESPath](http://jmespath.o
To assign the role to a specific organization include the `X-Grafana-Org-Id` header along with your JWT when making API requests to Grafana.
To learn more about the header, please refer to the [documentation](../../../../developers/http_api/#x-grafana-org-id-header).
### JMESPath examples
### Configure role mapping
To ease configuration of a proper JMESPath expression, you can test/evaluate expressions with custom payloads at http://jmespath.org/.
Unless `skip_org_role_sync` option is enabled, the user's role will be set to the role retrieved from the JWT.
### Role mapping
The user's role is retrieved using a [JMESPath](http://jmespath.org/examples.html) expression from the `role_attribute_path` configuration option.
To map the server administrator role, use the `allow_assign_grafana_admin` configuration option.
If the `role_attribute_path` property does not return a role, then the user is assigned the `Viewer` role by default. You can disable the role assignment by setting `role_attribute_strict = true`. It denies user access if no role or an invalid role is returned.
If no valid role is found, the user is assigned the role specified by [the `auto_assign_org_role` option](../../../configure-grafana/#auto_assign_org_role).
You can disable this default role assignment by setting `role_attribute_strict = true`. This setting denies user access if no role or an invalid role is returned after evaluating the `role_attribute_path` and the `org_mapping` expressions.
You can use the `org_attribute_path` and `org_mapping` configuration options to assign the user to organizations and specify their role. For more information, refer to [Org roles mapping example](#org-roles-mapping-example). If both org role mapping (`org_mapping`) and the regular role mapping (`role_attribute_path`) are specified, then the user will get the highest of the two mapped roles.
To ease configuration of a proper JMESPath expression, go to [JMESPath](http://jmespath.org/) to test and evaluate expressions with custom payloads.
**Basic example:**
@ -224,9 +230,9 @@ Payload:
}
```
Config:
Configuration:
```bash
```ini
role_attribute_path = role
```
@ -251,12 +257,40 @@ Payload:
}
```
Config:
Configuration:
```bash
```ini
role_attribute_path = contains(info.roles[*], 'admin') && 'Admin' || contains(info.roles[*], 'editor') && 'Editor' || 'Viewer'
```
**Org roles mapping example**
In the following example, the , the user has been granted the role of a `Viewer` in the `org_foo` organization, and the role of an `Editor` in the `org_bar` and `org_baz` organizations.
Payload:
```json
{
...
"info": {
...
"orgs": [
"engineer",
"admin",
],
...
},
...
}
```
Configuration:
```ini
org_attribute_path = info.orgs
org_mapping = engineer:org_foo:Viewer admin:org_bar:Editor *:org_baz:Editor
```
### Grafana Admin Role
If the `role_attribute_path` property returns a `GrafanaAdmin` role, Grafana Admin is not assigned by default, instead the `Admin` role is assigned. To allow `Grafana Admin` role to be assigned set `allow_assign_grafana_admin = true`.

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 {

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)
}

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,

View File

@ -1,6 +1,10 @@
package setting
import "time"
import (
"time"
"github.com/grafana/grafana/pkg/util"
)
const (
extJWTAccessTokenExpectAudience = "grafana"
@ -22,6 +26,8 @@ type AuthJWTSettings struct {
AutoSignUp bool
RoleAttributePath string
RoleAttributeStrict bool
OrgMapping []string
OrgAttributePath string
AllowAssignGrafanaAdmin bool
SkipOrgRoleSync bool
GroupsAttributePath string
@ -71,6 +77,8 @@ func (cfg *Cfg) readAuthJWTSettings() {
jwtSettings.EmailAttributePath = valueAsString(authJWT, "email_attribute_path", "")
jwtSettings.UsernameAttributePath = valueAsString(authJWT, "username_attribute_path", "")
jwtSettings.TlsSkipVerify = authJWT.Key("tls_skip_verify_insecure").MustBool(false)
jwtSettings.OrgAttributePath = valueAsString(authJWT, "org_attribute_path", "")
jwtSettings.OrgMapping = util.SplitString(valueAsString(authJWT, "org_mapping", ""))
cfg.JWTAuth = jwtSettings
}