mirror of https://github.com/grafana/grafana.git
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:
parent
ea89499209
commit
aeca9a80a4
|
@ -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]
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue