From aeca9a80a4ea08fa6a692e9ea11dc0281d3ab043 Mon Sep 17 00:00:00 2001 From: Quentin Bisson Date: Fri, 21 Mar 2025 14:18:53 +0100 Subject: [PATCH] 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 --- conf/defaults.ini | 4 +- conf/sample.ini | 6 +- .../configure-authentication/jwt/index.md | 50 ++++++- pkg/services/authn/authnimpl/registration.go | 4 +- pkg/services/authn/clients/jwt.go | 69 +++++---- pkg/services/authn/clients/jwt_test.go | 137 +++++++++++++++++- pkg/setting/setting_jwt.go | 10 +- 7 files changed, 234 insertions(+), 46 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 55378a7f6ee..a4679a13a6f 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -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] diff --git a/conf/sample.ini b/conf/sample.ini index ea2cb9a00df..f06e41efed9 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -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] diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md index 29dfa1f17ae..26ac14c8188 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/jwt/index.md @@ -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`. diff --git a/pkg/services/authn/authnimpl/registration.go b/pkg/services/authn/authnimpl/registration.go index bad45e88ef5..444ae241c61 100644 --- a/pkg/services/authn/authnimpl/registration.go +++ b/pkg/services/authn/authnimpl/registration.go @@ -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 { diff --git a/pkg/services/authn/clients/jwt.go b/pkg/services/authn/clients/jwt.go index edc1f43dbc9..82323e21bfe 100644 --- a/pkg/services/authn/clients/jwt.go +++ b/pkg/services/authn/clients/jwt.go @@ -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) +} diff --git a/pkg/services/authn/clients/jwt_test.go b/pkg/services/authn/clients/jwt_test.go index c4381f3945e..7fccffc6b17 100644 --- a/pkg/services/authn/clients/jwt_test.go +++ b/pkg/services/authn/clients/jwt_test.go @@ -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, diff --git a/pkg/setting/setting_jwt.go b/pkg/setting/setting_jwt.go index 2a559a145b7..18c7866cccf 100644 --- a/pkg/setting/setting_jwt.go +++ b/pkg/setting/setting_jwt.go @@ -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 }