From 5f51a1425b7950fd5f1920d897b4ec1e95f3fb42 Mon Sep 17 00:00:00 2001 From: Jo Date: Mon, 19 Sep 2022 09:33:47 +0200 Subject: [PATCH] Backport: Revert changes to oauth behavior to v9.1.x (#55302) * OAuth: Allow assigning Server Admin (#54780) * extract errors to errors file * implement oauth server admin assignment * add server admin tests * deduplicate autoAssignOrgRole * deduplicate strict setting * deduplicate strict setting * add support for generic oauth * add role attribute strict support for generic oauth * add support for github/gitlab * assignGrafanaAdmin option is here to stay * unify similar errors * add config option * add okta server admin mapping * remove never used Company attribute * unify generic oauth role extract with other methods * case insensitive role match as in azure * add ini settings * add server admin to devenv * remove duplicate fields * add documentation to oauth * fix titlecase test * implement doc feedback (cherry picked from commit ef245874da264e2b2f58011cff4f989d8d61ad9e) * Auth: Restore legacy behavior and add deprecation notice for empty org role in oauth (#55118) * Auth: Add deprecation notice for empty org role Co-authored-by: Gabriel MABILLE * fix recasts * fix azure tests missing logger * Adding test to gitlab oauth * Covering more cases * Cover more options * Add role attributestrict check fail * Adding one more edge case test * Using legacy for gitlab * Yet another edge case YAEC * Reverting github oauth to legacy Co-authored-by: Jguer * Not using token Co-authored-by: Jguer * Nit. * Adding warning in docs Co-authored-by: Jguer * add warning to generic oauth Co-authored-by: Jguer * Be more precise Co-authored-by: Jguer * Adding warning to github oauth Co-authored-by: Jguer * Adding warning to gitlab oauth Co-authored-by: Jguer * Adding warning to okta oauth Co-authored-by: Jguer * Add docs about mapping to AzureAD Co-authored-by: Jguer * Clarify oauth_skip_org_role_update_sync Co-authored-by: Jguer * Nit. * Nit on Azure AD Co-authored-by: Jguer * Reorder docs index Co-authored-by: Jguer * Fix typo Co-authored-by: Jguer Co-authored-by: Gabriel MABILLE Co-authored-by: gamab (cherry picked from commit 00e7324bf6ca327d38fc8a61ae2add2b31f533b5) * Auth: Allow admins to manually change oauth user role if `oauth_skip_org_role_update_sync` is enabled (#55182) * Auth: Allow admins to change oauth user info it it's not synced. Co-authored-by: Gabriel MABILLE * Update public/app/features/admin/UserAdminPage.tsx Co-authored-by: Alex Khomenko * Add missing import * Simplify init Co-authored-by: Josh Hunt Co-authored-by: Alex Khomenko * SAML: Add option to skip org role sync (#55230) * SAML: Add option to skip org role sync * Modify frontend accordingly * Remove update from config option name Co-authored-by: Jguer * Remove update from config option name Co-authored-by: Jguer * Fix typo Co-authored-by: Jguer Co-authored-by: Gabriel MABILLE Co-authored-by: Alex Khomenko Co-authored-by: gamab Co-authored-by: Josh Hunt (cherry picked from commit 3e2e9f93b96ad565607a30387cd230ef87a4e985) * Update gitlab_oauth_test.go * Update gitlab_oauth_test.go --- .betterer.results | 8 +- conf/defaults.ini | 5 + conf/sample.ini | 9 + devenv/docker/blocks/oauth/cloak.sql | 9 +- devenv/docker/blocks/oauth/readme.md | 10 +- .../setup-grafana/configure-grafana/_index.md | 9 +- .../configure-authentication/azuread.md | 45 +++- .../configure-authentication/generic-oauth.md | 62 +++-- .../configure-authentication/github.md | 35 +++ .../configure-authentication/gitlab.md | 46 +++- .../configure-authentication/okta.md | 35 +++ .../configure-authentication/saml.md | 13 +- packages/grafana-data/src/types/config.ts | 6 + packages/grafana-data/src/types/index.ts | 1 + packages/grafana-runtime/src/config.ts | 6 +- pkg/api/frontendsettings.go | 4 + pkg/api/login_oauth.go | 19 +- pkg/login/social/azuread_oauth.go | 63 +++-- pkg/login/social/azuread_oauth_test.go | 239 +++++++++++------- pkg/login/social/errors.go | 9 + pkg/login/social/generic_oauth.go | 33 +-- pkg/login/social/generic_oauth_test.go | 52 +++- pkg/login/social/github_oauth.go | 25 +- pkg/login/social/github_oauth_test.go | 56 ++-- pkg/login/social/gitlab_oauth.go | 28 +- pkg/login/social/gitlab_oauth_test.go | 159 ++++++++++++ pkg/login/social/grafana_com_oauth.go | 4 +- pkg/login/social/okta_oauth.go | 49 ++-- pkg/login/social/social.go | 205 ++++++++------- public/app/features/admin/UserAdminPage.tsx | 12 +- 30 files changed, 886 insertions(+), 370 deletions(-) create mode 100644 pkg/login/social/errors.go create mode 100644 pkg/login/social/gitlab_oauth_test.go diff --git a/.betterer.results b/.betterer.results index 75981e3418f..611e8604ede 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1,5 +1,5 @@ // BETTERER RESULTS V2. -// +// // If this file contains merge conflicts, use `betterer merge` to automatically resolve them: // https://phenomnomnominal.github.io/betterer/docs/results-file/#merge // @@ -1102,11 +1102,9 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], "packages/grafana-runtime/src/config.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Do not use any type assertions.", "3"], - [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "packages/grafana-runtime/src/services/AngularLoader.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/conf/defaults.ini b/conf/defaults.ini index b2df07d7342..f91fe98508e 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -472,6 +472,7 @@ team_ids = allowed_organizations = role_attribute_path = role_attribute_strict = false +allow_assign_grafana_admin = false #################################### GitLab Auth ######################### [auth.gitlab] @@ -487,6 +488,7 @@ allowed_domains = allowed_groups = role_attribute_path = role_attribute_strict = false +allow_assign_grafana_admin = false #################################### Google Auth ######################### [auth.google] @@ -532,6 +534,7 @@ token_url = https://login.microsoftonline.com//oauth2/v2.0/token allowed_domains = allowed_groups = role_attribute_strict = false +allow_assign_grafana_admin = false #################################### Okta OAuth ####################### [auth.okta] @@ -549,6 +552,7 @@ allowed_domains = allowed_groups = role_attribute_path = role_attribute_strict = false +allow_assign_grafana_admin = false #################################### Generic OAuth ####################### [auth.generic_oauth] @@ -582,6 +586,7 @@ tls_client_key = tls_client_ca = use_pkce = false auth_style = +allow_assign_grafana_admin = false #################################### Basic Auth ########################## [auth.basic] diff --git a/conf/sample.ini b/conf/sample.ini index eb974068a0b..fefec750c84 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -470,6 +470,9 @@ ;allowed_domains = ;team_ids = ;allowed_organizations = +;role_attribute_path = +;role_attribute_strict = false +;allow_assign_grafana_admin = false #################################### GitLab Auth ######################### [auth.gitlab] @@ -483,6 +486,9 @@ ;api_url = https://gitlab.com/api/v4 ;allowed_domains = ;allowed_groups = +;role_attribute_path = +;role_attribute_strict = false +;allow_assign_grafana_admin = false #################################### Google Auth ########################## [auth.google] @@ -519,6 +525,7 @@ ;allowed_domains = ;allowed_groups = ;role_attribute_strict = false +;allow_assign_grafana_admin = false #################################### Okta OAuth ####################### [auth.okta] @@ -535,6 +542,7 @@ ;allowed_groups = ;role_attribute_path = ;role_attribute_strict = false +;allow_assign_grafana_admin = false #################################### Generic OAuth ########################## [auth.generic_oauth] @@ -567,6 +575,7 @@ ;tls_client_ca = ;use_pkce = false ;auth_style = +;allow_assign_grafana_admin = false #################################### Basic Auth ########################## [auth.basic] diff --git a/devenv/docker/blocks/oauth/cloak.sql b/devenv/docker/blocks/oauth/cloak.sql index b8f933d4723..06b00843e01 100644 --- a/devenv/docker/blocks/oauth/cloak.sql +++ b/devenv/docker/blocks/oauth/cloak.sql @@ -2218,6 +2218,7 @@ d4b2c483-1dd3-47f6-86bf-42548009918d \N password 74e29604-ff35-42bb-a26d-4d0b81e b8c9b8b4-5943-43fe-9274-d63fd3e4a139 \N password c685749a-645e-4396-b9ee-6eedbfd89d5e 1656420634344 \N {"value":"IAOFzbDfWwzosZc+Z5nFm/i0B4foqmU4Q0EKG34RU3iwlIYUseEB3BoJqLEfM3Rj9oOSryEbCzblWRDS/5Padw==","salt":"7VR1+KwLVRZ6PenxaQoQTA==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 94aeafd3-71a5-4966-b2b6-34a083df6e92 \N password bdce2246-bb51-4f55-bb81-b7b8856225bc 1656425248776 \N {"value":"uD8KlRNocvZwYq1VZUShVp88zEtMUEeQnLYkW8ZvZXDdn1w1EahwnpNWYIc5QewEm3Nnf3DBYlUUrrbMC4XyfQ==","salt":"REwgUSsxRA/sqM5ujSrpcg==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 624725ce-9e36-4501-8bc8-ec39ee6b98d5 \N password 56eff2b3-e36a-4e3e-84a1-361ad312667b 1656428741229 \N {"value":"4UBzDNd3oPxP54/z7ez1Bd3xSfKJBpbE3rQppM3Xg+2bLaLNoU90TPEK+8SWbpMAFBKHz53qPWrZ50MbNgcGSA==","salt":"iTNvn3xr0acn9wqQxJ3d/A==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 +77f9adeb-4bd6-47bd-93d6-49ac90edc731 \N password b8aada79-3fb4-45cd-95d0-c046f3a0113a 1662476251794 \N {"value":"dQJruhADrlLXvwYwd3L2S7ie5FWLGFxJVZm2Eog92xUH2+oahsM52tFvVfsI4wlbAN+XBqMGsfz9rsXeROWvXw==","salt":"64V0IRC+zdOkJ8l4ejfmHA==","additionalParameters":{}} {"hashIterations":27500,"algorithm":"pbkdf2-sha256","additionalParameters":{}} 10 \. @@ -2571,9 +2572,9 @@ b8a4faaf-86d9-43eb-bb18-0eaa654b35a7 ef7f6eac-9fff-44aa-a86c-5125d52acc82 t ${ro c49bddc6-ec92-4caa-bc04-57ba80a92eb9 grafana f ${role_offline-access} offline_access grafana \N grafana 0f3d47bb-002a-4cd0-a502-725f224308a7 grafana f ${role_uma_authorization} uma_authorization grafana \N grafana 60f1b1ea-9059-41ea-acef-573643b24709 grafana f Grafana Organization Administrator admin grafana \N grafana -c029a218-4519-4537-ae12-d8f3c27a0003 grafana f Grafana Server Admin serveradmin grafana \N grafana c9a776f9-2740-435f-a725-4dbcc17a6c91 grafana f Grafana Viewer viewer grafana \N grafana c4c74006-c346-48cf-8cf1-1617e3e1cde1 grafana f Grafana Editor editor grafana \N grafana +c90ad7c8-d14b-46ed-b94d-2de3baa50ff7 grafana f Grafana Server Admin grafanaadmin grafana \N grafana \. @@ -3301,6 +3302,7 @@ COPY public.user_entity (id, email, email_constraint, email_verified, enabled, f c685749a-645e-4396-b9ee-6eedbfd89d5e oauth-admin@example.org oauth-admin@example.org f t \N Admin Oauth grafana oauth-admin 1656418530879 \N 0 56eff2b3-e36a-4e3e-84a1-361ad312667b oauth-editor@example.org oauth-editor@example.org f t \N Editor Oauth grafana oauth-editor 1656418563005 \N 0 bdce2246-bb51-4f55-bb81-b7b8856225bc oauth-viewer@example.org oauth-viewer@example.org f t \N Viewer Oauth grafana oauth-viewer 1656425237046 \N 0 +b8aada79-3fb4-45cd-95d0-c046f3a0113a oauth-grafanaadmin@example.org oauth-grafanaadmin@example.org t t \N Grafanaadmin Oauth grafana oauth-grafanaadmin 1662476222024 \N 0 \. @@ -3376,6 +3378,11 @@ c49bddc6-ec92-4caa-bc04-57ba80a92eb9 bdce2246-bb51-4f55-bb81-b7b8856225bc 0f3d47bb-002a-4cd0-a502-725f224308a7 bdce2246-bb51-4f55-bb81-b7b8856225bc f1311ecb-6a6a-49d6-bb16-5132daf93a64 bdce2246-bb51-4f55-bb81-b7b8856225bc 18a7066b-fe71-410e-9581-69f78347ec29 bdce2246-bb51-4f55-bb81-b7b8856225bc +c49bddc6-ec92-4caa-bc04-57ba80a92eb9 b8aada79-3fb4-45cd-95d0-c046f3a0113a +0f3d47bb-002a-4cd0-a502-725f224308a7 b8aada79-3fb4-45cd-95d0-c046f3a0113a +f1311ecb-6a6a-49d6-bb16-5132daf93a64 b8aada79-3fb4-45cd-95d0-c046f3a0113a +18a7066b-fe71-410e-9581-69f78347ec29 b8aada79-3fb4-45cd-95d0-c046f3a0113a +c90ad7c8-d14b-46ed-b94d-2de3baa50ff7 b8aada79-3fb4-45cd-95d0-c046f3a0113a \. diff --git a/devenv/docker/blocks/oauth/readme.md b/devenv/docker/blocks/oauth/readme.md index 63c8f3ae008..af93fb0e980 100644 --- a/devenv/docker/blocks/oauth/readme.md +++ b/devenv/docker/blocks/oauth/readme.md @@ -26,7 +26,8 @@ name_attribute_path = name auth_url = http://localhost:8087/auth/realms/grafana/protocol/openid-connect/auth token_url = http://localhost:8087/auth/realms/grafana/protocol/openid-connect/token api_url = http://localhost:8087/auth/realms/grafana/protocol/openid-connect/userinfo -role_attribute_path = contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer' +role_attribute_path = contains(roles[*], 'grafanaadmin') && 'GrafanaAdmin' || contains(roles[*], 'admin') && 'Admin' || contains(roles[*], 'editor') && 'Editor' || 'Viewer' +allow_assign_grafana_admin = true ``` ## Devenv setup jwt auth @@ -112,9 +113,10 @@ docker-compose exec -T oauthkeycloakdb bash -c "pg_dump -U keycloak keycloak" > - keycloak admin: http://localhost:8087 - keycloak admin login: admin:admin -- grafana oauth viewer login: oauth-viewer:grafana -- grafana oauth editor login: oauth-editor:grafana -- grafana oauth admin login: oauth-admin:grafana +- grafana oauth viewer login: oauth-viewer:grafana +- grafana oauth editor login: oauth-editor:grafana +- grafana oauth admin login: oauth-admin:grafana +- grafana oauth server admin login: oauth-grafanaadmin:grafana # Troubleshooting diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index fb65099f760..ece945a6895 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -792,8 +792,13 @@ Administrators can increase this if they experience OAuth login state mismatch e ### oauth_skip_org_role_update_sync Skip forced assignment of OrgID `1` or `auto_assign_org_id` for external logins. Default is `false`. -Use this setting to distribute users with external login to multiple organizations. -Otherwise, the users' organization would get reset on every new login, for example, via AzureAD. +Use this setting to allow users with external login to be manually assigned to multiple organizations. + +By default, the users' organization and role is reset on every new login. + +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't update the user's organization role. +> With Grafana 10, if `oauth_skip_org_role_update_sync` option is set to `false`, users with no mapping will be +> reset to the default organization role on every login. [See `auto_assign_org_role` option]({{< relref ".#auto_assign_org_role" >}}). ### api_key_max_seconds_to_live diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread.md b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread.md index 7af69ddee48..c34cef88111 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/azuread.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/azuread.md @@ -61,8 +61,8 @@ To enable the Azure AD OAuth2, register your application with Azure AD. "allowedMemberTypes": [ "User" ], - "description": "Grafana admin Users", - "displayName": "Grafana Admin", + "description": "Grafana org admin Users", + "displayName": "Grafana Org Admin", "id": "SOME_UNIQUE_ID", "isEnabled": true, "lang": null, @@ -100,6 +100,46 @@ To enable the Azure AD OAuth2, register your application with Azure AD. 1. Click on **Users and Groups** and add Users/Groups to the Grafana roles by using **Add User**. +### Map roles + +By default, Azure AD authentication will map users to organization roles based on the most privileged application role assigned to the user in AzureAD. + +If no application role is found, the user is assigned the role specified by +[the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). +You can disable this default role assignment by setting `role_attribute_strict = true`. +It denies user access if no role or an invalid role is returned. + +**On every login** the user organization role will be reset to match AzureAD's application role and +their organization membership will be reset to the default organization. + +If Azure AD authentication is not intended to sync user roles and organization membership, +`oauth_skip_org_role_update_sync` should be enabled. +See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more details. + +### Assign server administrator privileges + +> Available in Grafana v9.2 and later versions. + +If the application role received by Grafana is `GrafanaAdmin`, Grafana grants the user server administrator privileges. +This is useful if you want to grant server administrator privileges to a subset of users. +Grafana also assigns the user the `Admin` role of the default organization. + +The setting `allow_assign_grafana_admin` under `[auth.azuread]` must be set to `true` for this to work. +If the setting is set to `false`, the user is assigned the role of `Admin` of the default organization, but not server administrator privileges. + +```json +{ + "allowedMemberTypes": ["User"], + "description": "Grafana server admin Users", + "displayName": "Grafana Server Admin", + "id": "SOME_UNIQUE_ID", + "isEnabled": true, + "lang": null, + "origin": "Application", + "value": "GrafanaAdmin" +} +``` + ## Enable Azure AD OAuth in Grafana 1. Add the following to the [Grafana configuration file]({{< relref "../../configure-grafana/#config-file-locations" >}}): @@ -117,6 +157,7 @@ token_url = https://login.microsoftonline.com/TENANT_ID/oauth2/v2.0/token allowed_domains = allowed_groups = role_attribute_strict = false +allow_assign_grafana_admin = false ``` You can also use these environment variables to configure **client_id** and **client_secret**: diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth.md b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth.md index 4033bfc3942..9d7c15b93a3 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/generic-oauth.md @@ -21,9 +21,8 @@ You can configure many different OAuth2 authentication services with Grafana usi - [Set up OAuth2 with Bitbucket](#set-up-oauth2-with-bitbucket) - [Set up OAuth2 with Centrify](#set-up-oauth2-with-centrify) - [Set up OAuth2 with OneLogin](#set-up-oauth2-with-onelogin) - - [JMESPath examples](#jmespath-examples) - - [Role mapping](#role-mapping) - - [Groups mapping](#groups-mapping) + - [Role mapping](#role-mapping) + - [Team synchronization](#team-synchronization) This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the suffixed path of `/login/generic_oauth`. @@ -80,12 +79,6 @@ Grafana determines a user's email address by querying the OAuth provider until i 1. Query the `/emails` endpoint of the OAuth provider's API (configured with `api_url`), then check for the presence of an email address marked as a primary address. 1. If no email address is found in steps (1-4), then the email address of the user is set to an empty string. -### Roles - -Grafana checks for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_attribute_path` configuration option. The JMESPath is applied to the `id_token` first. If there is no match, then the UserInfo endpoint specified via the `api_url` configuration option is tried next. The result after evaluation of the `role_attribute_path` JMESPath expression should be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. - -For more information, refer to the [JMESPath examples](#jmespath-examples). - ### Groups / Teams Similarly, group mappings are made using [JMESPath](http://jmespath.org/examples.html) with the `groups_attribute_path` configuration option. The `id_token` is attempted first, followed by the UserInfo from the `api_url`. The result of the JMESPath expression should be a string array of groups. @@ -241,14 +234,32 @@ allowed_organizations = allowed_organizations = ``` -## JMESPath examples +## Role Mapping + +Grafana checks for the presence of a role using the [JMESPath](http://jmespath.org/examples.html) specified via the `role_attribute_path` configuration option. The JMESPath is applied to the `id_token` first. If there is no match, then the UserInfo endpoint specified via the `api_url` configuration option is tried next. The result after evaluation of the `role_attribute_path` JMESPath expression should be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. + +For more information, refer to the [JMESPath examples](#jmespath-examples). + +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't +> update the user's organization role. This is going to change in Grafana 10. To avoid overriding manually set roles, +> enable the `oauth_skip_org_role_update_sync` option. +> See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more information. + +On first login, if the`role_attribute_path` property does not return a role, then the user is assigned the role +specified by [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). +You can disable this default role assignment by setting `role_attribute_strict = true`. +It denies user access if no role or an invalid role is returned. + +> **Warning**: With Grafana 10, **on every login**, if the`role_attribute_path` property does not return a role, +> then the user is assigned the role specified by +> [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). + +### JMESPath examples + +#### Map user organization role To ease configuration of a proper JMESPath expression, you can test/evaluate expressions with custom payloads at http://jmespath.org/. -### Role mapping - -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. - **Basic example:** In the following example user will get `Editor` as role when authenticating. The value of the property `role` will be the resulting role if the role is a proper Grafana role, i.e. `Viewer`, `Editor` or `Admin`. @@ -296,7 +307,28 @@ Config: role_attribute_path = contains(info.roles[*], 'admin') && 'Admin' || contains(info.roles[*], 'editor') && 'Editor' || 'Viewer' ``` -### Groups mapping +#### Map server administrator privileges + +> Available in Grafana v9.2 and later versions. + +If the application role received by Grafana is `GrafanaAdmin`, Grafana grants the user server administrator privileges. +This is useful if you want to grant server administrator privileges to a subset of users. +Grafana also assigns the user the `Admin` role of the default organization. + +The setting `allow_assign_grafana_admin` under `[auth.generic_oauth]` must be set to `true` for this to work. +If the setting is set to `false`, the user is assigned the role of `Admin` of the default organization, but not server administrator privileges. + +```ini +allow_assign_grafana_admin = true +``` + +Example: + +```ini +role_attribute_path = contains(info.roles[*], 'admin') && 'GrafanaAdmin' || contains(info.roles[*], 'editor') && 'Editor' || 'Viewer' +``` + +## Team synchronization > Available in Grafana Enterprise v8.1 and later versions. diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/github.md b/docs/sources/setup-grafana/configure-security/configure-authentication/github.md index 557ef871282..c1bcdd4aa09 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/github.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/github.md @@ -109,6 +109,20 @@ For the path lookup, Grafana uses JSON obtained from querying GitHub's API [`/ap The result of evaluating the `role_attribute_path` JMESPath expression must be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. For more information about roles and permissions in Grafana, refer to [Roles and permissions]({{< relref "../../../administration/roles-and-permissions/" >}}). +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't +> update the user's organization role. This is going to change in Grafana 10. To avoid overriding manually set roles, +> enable the `oauth_skip_org_role_update_sync` option. +> See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more information. + +On first login, if the`role_attribute_path` property does not return a role, then the user is assigned the role +specified by [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). +You can disable this default role assignment by setting `role_attribute_strict = true`. +It denies user access if no role or an invalid role is returned. + +> **Warning**: With Grafana 10, **on every login**, if the`role_attribute_path` property does not return a role, +> then the user is assigned the role specified by +> [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). + An example Query could look like the following: ```bash @@ -130,6 +144,27 @@ role_attribute_path = contains(groups[*], '@github/example-group') && 'Editor' | Note: If a match is found in other fields, teams will be ignored. +#### Map server administrator privileges + +> Available in Grafana v9.2 and later versions. + +If the application role received by Grafana is `GrafanaAdmin`, Grafana grants the user server administrator privileges. +This is useful if you want to grant server administrator privileges to a subset of users. +Grafana also assigns the user the `Admin` role of the default organization. + +The setting `allow_assign_grafana_admin` under `[auth.github]` must be set to `true` for this to work. +If the setting is set to `false`, the user is assigned the role of `Admin` of the default organization, but not server administrator privileges. + +```ini +allow_assign_grafana_admin = true +``` + +Example: + +```ini +role_attribute_path = [login==octocat] && 'GrafanaAdmin' || 'Viewer' +``` + ### Team Sync (Enterprise only) > Only available in Grafana Enterprise v6.3+ diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md index 0638161f0e0..a503795ddc4 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/gitlab.md @@ -58,6 +58,9 @@ auth_url = https://gitlab.com/oauth/authorize token_url = https://gitlab.com/oauth/token api_url = https://gitlab.com/api/v4 allowed_groups = +role_attribute_path = +role_attribute_strict = false +allow_assign_grafana_admin = false ``` You may have to set the `root_url` option of `[server]` for the callback URL to be @@ -102,7 +105,7 @@ characters. Make sure you always use the group or subgroup name as it appears in the URL of the group or subgroup. Here's a complete example with `allow_sign_up` enabled, with access limited to -the `example` and `foo/bar` groups. The example also promotes all GitLab Admins to Grafana Admins: +the `example` and `foo/bar` groups. The example also promotes all GitLab Admins to Grafana organization admins: ```ini [auth.gitlab] @@ -116,6 +119,8 @@ token_url = https://gitlab.com/oauth/token api_url = https://gitlab.com/api/v4 allowed_groups = example, foo/bar role_attribute_path = is_admin && 'Admin' || 'Viewer' +role_attribute_strict = true +allow_assign_grafana_admin = false ``` ### Map roles @@ -124,9 +129,23 @@ You can use GitLab OAuth to map roles. During mapping, Grafana checks for the pr For the path lookup, Grafana uses JSON obtained from querying GitLab's API [`/api/v4/user`](https://docs.gitlab.com/ee/api/users.html#list-current-user-for-normal-users) endpoint and a `groups` key containing all of the user's teams. The result of evaluating the `role_attribute_path` JMESPath expression must be a valid Grafana role, for example, `Viewer`, `Editor` or `Admin`. For more information about roles and permissions in Grafana, refer to [Roles and permissions]({{< relref "../../../administration/roles-and-permissions/" >}}). +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't +> update the user's organization role. This is going to change in Grafana 10. To avoid overriding manually set roles, +> enable the `oauth_skip_org_role_update_sync` option. +> See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more information. + +On first login, if the`role_attribute_path` property does not return a role, then the user is assigned the role +specified by [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). +You can disable this default role assignment by setting `role_attribute_strict = true`. +It denies user access if no role or an invalid role is returned. + +> **Warning**: With Grafana 10, **on every login**, if the`role_attribute_path` property does not return a role, +> then the user is assigned the role specified by +> [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). + An example Query could look like the following: -```bash +```ini role_attribute_path = is_admin && 'Admin' || 'Viewer' ``` @@ -139,12 +158,33 @@ Groups can also be used to map roles. Group name (lowercased and unique) is used For instance, if you have a group with display name 'Example-Group' you can use the following snippet to ensure those members inherit the role 'Editor'. -```bash +```ini role_attribute_path = contains(groups[*], 'example-group') && 'Editor' || 'Viewer' ``` Note: If a match is found in other fields, groups will be ignored. +#### Map server administrator privileges + +> Available in Grafana v9.2 and later versions. + +If the application role received by Grafana is `GrafanaAdmin`, Grafana grants the user server administrator privileges. +This is useful if you want to grant server administrator privileges to a subset of users. +Grafana also assigns the user the `Admin` role of the default organization. + +The setting `allow_assign_grafana_admin` under `[auth.gitlab]` must be set to `true` for this to work. +If the setting is set to `false`, the user is assigned the role of `Admin` of the default organization, but not server administrator privileges. + +```ini +allow_assign_grafana_admin = true +``` + +Example: + +```ini +role_attribute_path = is_admin && 'GrafanaAdmin' || 'Viewer' +``` + ### Team Sync (Enterprise only) > Only available in Grafana Enterprise v6.4+ diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/okta.md b/docs/sources/setup-grafana/configure-security/configure-authentication/okta.md index 75e27e598e3..49202442c0e 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/okta.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/okta.md @@ -75,8 +75,43 @@ Grafana can attempt to do role mapping through Okta OAuth. In order to achieve t Grafana uses JSON obtained from querying the `/userinfo` endpoint for the path lookup. The result after evaluating the `role_attribute_path` JMESPath expression needs to be a valid Grafana role, i.e. `Viewer`, `Editor` or `Admin`. For more information about roles and permissions in Grafana, refer to [Roles and permissions]({{< relref "../../../administration/roles-and-permissions/" >}}). +> **Warning**: Currently if no organization role mapping is found for a user, Grafana doesn't +> update the user's organization role. This is going to change in Grafana 10. To avoid overriding manually set roles, +> enable the `oauth_skip_org_role_update_sync` option. +> See [configure-grafana]({{< relref "../../configure-grafana#oauth_skip_org_role_update_sync" >}}) for more information. + +On first login, if the`role_attribute_path` property does not return a role, then the user is assigned the role +specified by [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). +You can disable this default role assignment by setting `role_attribute_strict = true`. +It denies user access if no role or an invalid role is returned. + +> **Warning**: With Grafana 10, **on every login**, if the`role_attribute_path` property does not return a role, +> then the user is assigned the role specified by +> [the `auto_assign_org_role` option]({{< relref "../../configure-grafana#auto_assign_org_role" >}}). + Read about how to [add custom claims](https://developer.okta.com/docs/guides/customize-tokens-returned-from-okta/add-custom-claim/) to the user info in Okta. Also, check Generic OAuth page for [JMESPath examples]({{< relref "generic-oauth/#jmespath-examples" >}}). +#### Map server administrator privileges + +> Available in Grafana v9.2 and later versions. + +If the application role received by Grafana is `GrafanaAdmin`, Grafana grants the user server administrator privileges. +This is useful if you want to grant server administrator privileges to a subset of users. +Grafana also assigns the user the `Admin` role of the default organization. + +The setting `allow_assign_grafana_admin` under `[auth.okta]` must be set to `true` for this to work. +If the setting is set to `false`, the user is assigned the role of `Admin` of the default organization, but not server administrator privileges. + +```ini +allow_assign_grafana_admin = true +``` + +Example: + +```ini +role_attribute_path = contains(groups[*], 'admin') && 'GrafanaAdmin' || contains(groups[*], 'editor') && 'Editor' || 'Viewer' +``` + ### Team Sync (Enterprise only) Map your Okta groups to teams in Grafana so that your users will automatically be added to diff --git a/docs/sources/setup-grafana/configure-security/configure-authentication/saml.md b/docs/sources/setup-grafana/configure-security/configure-authentication/saml.md index de1a1694020..ad2f0efe675 100644 --- a/docs/sources/setup-grafana/configure-security/configure-authentication/saml.md +++ b/docs/sources/setup-grafana/configure-security/configure-authentication/saml.md @@ -297,7 +297,7 @@ For more information about roles and permissions in Grafana, refer to [Roles and Example configuration: -```bash +```ini [auth.saml] assertion_attribute_role = role role_values_editor = editor, developer @@ -307,6 +307,17 @@ role_values_grafana_admin = superadmin **Important**: When role sync is configured, any changes of user roles and organization membership made manually in Grafana will be overwritten on next user login. Assign user organizations and roles in the IdP instead. +> **Note:** Available in Grafana version 9.2 and later. + +If you don't want user organizations and roles to be synchronized with the IdP, you can use the `skip_org_role_sync` configuration option. + +Example configuration: + +```ini +[auth.saml] +skip_org_role_sync = true +``` + ### Configure organization mapping > **Note:** Available in Grafana version 7.0 and later. diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 61c474b65e6..1754daa3fa6 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -154,6 +154,7 @@ export interface GrafanaConfig { isPublicDashboardView: boolean; datasources: { [str: string]: DataSourceInstanceSettings }; panels: { [key: string]: PanelPluginMeta }; + auth: AuthSettings; minRefreshInterval: string; appSubUrl: string; windowTitlePrefix: string; @@ -216,3 +217,8 @@ export interface GrafanaConfig { rudderstackSdkUrl: string | undefined; rudderstackConfigUrl: string | undefined; } + +export interface AuthSettings { + OAuthSkipOrgRoleUpdateSync?: boolean; + SAMLSkipOrgRoleSync?: boolean; +} diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 63502faec91..f19076b5b5b 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -41,6 +41,7 @@ export { BootData, OAuth, OAuthSettings, + AuthSettings, GrafanaConfig, BuildInfo, LicenseInfo, diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index b22f47f1504..cbe9d3ad7f7 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -1,6 +1,7 @@ import { merge } from 'lodash'; import { + AuthSettings, BootData, BuildInfo, createTheme, @@ -27,6 +28,7 @@ export class GrafanaBootConfig implements GrafanaConfig { isPublicDashboardView: boolean; datasources: { [str: string]: DataSourceInstanceSettings } = {}; panels: { [key: string]: PanelPluginMeta } = {}; + auth: AuthSettings = {}; minRefreshInterval = ''; appUrl = ''; appSubUrl = ''; @@ -67,7 +69,7 @@ export class GrafanaBootConfig implements GrafanaConfig { loginHint = ''; passwordHint = ''; loginError = undefined; - navTree: any; + navTree: undefined; viewersCanEdit = false; editorsCanAdmin = false; disableSanitizeHtml = false; @@ -108,7 +110,7 @@ export class GrafanaBootConfig implements GrafanaConfig { pluginAdminExternalManageEnabled = false; pluginCatalogHiddenPlugins: string[] = []; expressionsEnabled = false; - customTheme?: any; + customTheme?: undefined; awsAllowedAuthProviders: string[] = []; awsAssumeRoleEnabled = false; azure: AzureSettings = { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 1d4ba7342be..1455763c0d9 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -135,6 +135,10 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i "editorsCanAdmin": hs.Cfg.EditorsCanAdmin, "disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml, "pluginsToPreload": pluginsToPreload, + "auth": map[string]interface{}{ + "OAuthSkipOrgRoleUpdateSync": hs.Cfg.OAuthSkipOrgRoleUpdateSync, + "SAMLSkipOrgRoleSync": hs.Cfg.SectionWithEnvOverrides("auth.saml").Key("skip_org_role_sync").MustBool(false), + }, "buildInfo": map[string]interface{}{ "hideVersion": hideVersion, "version": version, diff --git a/pkg/api/login_oauth.go b/pkg/api/login_oauth.go index 8495f08de71..f96716018f3 100644 --- a/pkg/api/login_oauth.go +++ b/pkg/api/login_oauth.go @@ -262,18 +262,19 @@ func (hs *HTTPServer) buildExternalUserInfo(token *oauth2.Token, userInfo *socia oauthLogger.Debug("Building external user info from OAuth user info") extUser := &models.ExternalUserInfo{ - AuthModule: fmt.Sprintf("oauth_%s", name), - OAuthToken: token, - AuthId: userInfo.Id, - Name: userInfo.Name, - Login: userInfo.Login, - Email: userInfo.Email, - OrgRoles: map[int64]models.RoleType{}, - Groups: userInfo.Groups, + AuthModule: fmt.Sprintf("oauth_%s", name), + OAuthToken: token, + AuthId: userInfo.Id, + Name: userInfo.Name, + Login: userInfo.Login, + Email: userInfo.Email, + OrgRoles: map[int64]models.RoleType{}, + Groups: userInfo.Groups, + IsGrafanaAdmin: userInfo.IsGrafanaAdmin, } if userInfo.Role != "" && !hs.Cfg.OAuthSkipOrgRoleUpdateSync { - rt := models.RoleType(userInfo.Role) + rt := userInfo.Role if rt.IsValid() { // The user will be assigned a role in either the auto-assigned organization or in the default one var orgID int64 diff --git a/pkg/login/social/azuread_oauth.go b/pkg/login/social/azuread_oauth.go index ecf4c70b793..9ac541b3123 100644 --- a/pkg/login/social/azuread_oauth.go +++ b/pkg/login/social/azuread_oauth.go @@ -3,7 +3,6 @@ package social import ( "bytes" "encoding/json" - "errors" "fmt" "io" "net/http" @@ -17,9 +16,7 @@ import ( type SocialAzureAD struct { *SocialBase - allowedGroups []string - autoAssignOrgRole string - roleAttributeStrict bool + allowedGroups []string } type azureClaims struct { @@ -52,7 +49,7 @@ func (s *SocialAzureAD) Type() int { func (s *SocialAzureAD) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { idToken := token.Extra("id_token") if idToken == nil { - return nil, fmt.Errorf("no id_token found") + return nil, ErrIDTokenNotFound } parsedToken, err := jwt.ParseSigned(idToken.(string)) @@ -67,13 +64,14 @@ func (s *SocialAzureAD) UserInfo(client *http.Client, token *oauth2.Token) (*Bas email := claims.extractEmail() if email == "" { - return nil, errors.New("error getting user info: no email found in access token") + return nil, ErrEmailNotFound } - role := claims.extractRole(s.autoAssignOrgRole, s.roleAttributeStrict) - if role == "" { - return nil, errors.New("user does not have a valid role") + role, grafanaAdmin := s.extractRoleAndAdmin(&claims) + if s.roleAttributeStrict && !role.IsValid() { + return nil, ErrInvalidBasicRole } + logger.Debug("AzureAD OAuth: extracted role", "email", email, "role", role) groups, err := extractGroups(client, claims, token) @@ -86,13 +84,19 @@ func (s *SocialAzureAD) UserInfo(client *http.Client, token *oauth2.Token) (*Bas return nil, errMissingGroupMembership } + var isGrafanaAdmin *bool = nil + if s.allowAssignGrafanaAdmin { + isGrafanaAdmin = &grafanaAdmin + } + return &BasicUserInfo{ - Id: claims.ID, - Name: claims.Name, - Email: email, - Login: email, - Role: string(role), - Groups: groups, + Id: claims.ID, + Name: claims.Name, + Email: email, + Login: email, + Role: role, + IsGrafanaAdmin: isGrafanaAdmin, + Groups: groups, }, nil } @@ -122,32 +126,24 @@ func (claims *azureClaims) extractEmail() string { return claims.Email } -func (claims *azureClaims) extractRole(autoAssignRole string, strictMode bool) models.RoleType { +// extractRoleAndAdmin extracts the role from the claims and returns the role and whether the user is a Grafana admin. +func (s *SocialAzureAD) extractRoleAndAdmin(claims *azureClaims) (models.RoleType, bool) { if len(claims.Roles) == 0 { - if strictMode { - return models.RoleType("") - } - - return models.RoleType(autoAssignRole) - } - - roleOrder := []models.RoleType{ - models.ROLE_ADMIN, - models.ROLE_EDITOR, - models.ROLE_VIEWER, + return s.defaultRole(false), false } + roleOrder := []models.RoleType{RoleGrafanaAdmin, models.ROLE_ADMIN, models.ROLE_EDITOR, models.ROLE_VIEWER} for _, role := range roleOrder { if found := hasRole(claims.Roles, role); found { - return role + if role == RoleGrafanaAdmin { + return models.ROLE_ADMIN, true + } + + return role, false } } - if strictMode { - return models.RoleType("") - } - - return models.ROLE_VIEWER + return s.defaultRole(false), false } func hasRole(roles []string, role models.RoleType) bool { @@ -156,6 +152,7 @@ func hasRole(roles []string, role models.RoleType) bool { return true } } + return false } diff --git a/pkg/login/social/azuread_oauth_test.go b/pkg/login/social/azuread_oauth_test.go index a91c3da3516..2d4ee77deea 100644 --- a/pkg/login/social/azuread_oauth_test.go +++ b/pkg/login/social/azuread_oauth_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "reflect" "strings" "testing" "time" @@ -16,12 +15,20 @@ import ( "gopkg.in/square/go-jose.v2/jwt" ) +func trueBoolPtr() *bool { + b := true + return &b +} + +func falseBoolPtr() *bool { + b := false + return &b +} + func TestSocialAzureAD_UserInfo(t *testing.T) { type fields struct { - SocialBase *SocialBase - allowedGroups []string - autoAssignOrgRole string - roleAttributeStrict bool + SocialBase *SocialBase + allowedGroups []string } type args struct { client *http.Client @@ -46,16 +53,15 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - autoAssignOrgRole: "Viewer", + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer"), }, want: &BasicUserInfo{ - Id: "1234", - Name: "My Name", - Email: "me@example.com", - Login: "me@example.com", - Company: "", - Role: "Viewer", - Groups: []string{}, + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Viewer", + Groups: []string{}, }, }, { @@ -86,16 +92,15 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - autoAssignOrgRole: "Viewer", + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer"), }, want: &BasicUserInfo{ - Id: "1234", - Name: "My Name", - Email: "me@example.com", - Login: "me@example.com", - Company: "", - Role: "Viewer", - Groups: []string{}, + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Viewer", + Groups: []string{}, }, }, { @@ -108,13 +113,12 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, want: &BasicUserInfo{ - Id: "1234", - Name: "My Name", - Email: "me@example.com", - Login: "me@example.com", - Company: "", - Role: "Admin", - Groups: []string{}, + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Admin", + Groups: []string{}, }, }, { @@ -127,17 +131,19 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, want: &BasicUserInfo{ - Id: "1234", - Name: "My Name", - Email: "me@example.com", - Login: "me@example.com", - Company: "", - Role: "Admin", - Groups: []string{}, + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Admin", + Groups: []string{}, }, }, { name: "Only other roles", + fields: fields{ + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Viewer"), + }, claims: &azureClaims{ Email: "me@example.com", PreferredUsername: "", @@ -146,13 +152,12 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, want: &BasicUserInfo{ - Id: "1234", - Name: "My Name", - Email: "me@example.com", - Login: "me@example.com", - Company: "", - Role: "Viewer", - Groups: []string{}, + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Viewer", + Groups: []string{}, }, }, { @@ -165,16 +170,15 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, fields: fields{ - autoAssignOrgRole: "Editor", + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "Editor"), }, want: &BasicUserInfo{ - Id: "1234", - Name: "My Name", - Email: "me@example.com", - Login: "me@example.com", - Company: "", - Role: "Editor", - Groups: []string{}, + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Editor", + Groups: []string{}, }, }, { @@ -187,13 +191,12 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, want: &BasicUserInfo{ - Id: "1234", - Name: "My Name", - Email: "me@example.com", - Login: "me@example.com", - Company: "", - Role: "Editor", - Groups: []string{}, + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Editor", + Groups: []string{}, }, }, { @@ -206,13 +209,75 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, want: &BasicUserInfo{ - Id: "1234", - Name: "My Name", - Email: "me@example.com", - Login: "me@example.com", - Company: "", - Role: "Admin", - Groups: []string{}, + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Admin", + Groups: []string{}, + }, + }, + { + name: "Grafana Admin but setting is disabled", + fields: fields{SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Editor")}, + claims: &azureClaims{ + Email: "me@example.com", + PreferredUsername: "", + Roles: []string{"GrafanaAdmin"}, + Name: "My Name", + ID: "1234", + }, + want: &BasicUserInfo{ + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Admin", + Groups: []string{}, + IsGrafanaAdmin: nil, + }, + }, + { + name: "Editor roles in claim and GrafanaAdminAssignment enabled", + fields: fields{ + SocialBase: newSocialBase("azuread", + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "")}, + claims: &azureClaims{ + Email: "me@example.com", + PreferredUsername: "", + Roles: []string{"Editor"}, + Name: "My Name", + ID: "1234", + }, + want: &BasicUserInfo{ + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Editor", + Groups: []string{}, + IsGrafanaAdmin: falseBoolPtr(), + }, + }, + { + name: "Grafana Admin and Editor roles in claim", + fields: fields{SocialBase: newSocialBase("azuread", + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: true}, "")}, + claims: &azureClaims{ + Email: "me@example.com", + PreferredUsername: "", + Roles: []string{"GrafanaAdmin", "Editor"}, + Name: "My Name", + ID: "1234", + }, + want: &BasicUserInfo{ + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Admin", + Groups: []string{}, + IsGrafanaAdmin: trueBoolPtr(), }, }, { @@ -234,8 +299,9 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Error if user is a member of allowed_groups", fields: fields{ - allowedGroups: []string{"foo", "bar"}, - autoAssignOrgRole: "Viewer", + allowedGroups: []string{"foo", "bar"}, + SocialBase: newSocialBase("azuread", + &oauth2.Config{}, &OAuthInfo{AllowAssignGrafanaAdmin: false}, "Viewer"), }, claims: &azureClaims{ Email: "me@example.com", @@ -246,13 +312,12 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { ID: "1234", }, want: &BasicUserInfo{ - Id: "1234", - Name: "My Name", - Email: "me@example.com", - Login: "me@example.com", - Company: "", - Role: "Viewer", - Groups: []string{"foo"}, + Id: "1234", + Name: "My Name", + Email: "me@example.com", + Login: "me@example.com", + Role: "Viewer", + Groups: []string{"foo"}, }, }, { @@ -283,7 +348,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch empty role when strict attribute role is true and no match", fields: fields{ - roleAttributeStrict: true, + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, ""), }, claims: &azureClaims{ Email: "me@example.com", @@ -299,7 +364,7 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { { name: "Fetch empty role when strict attribute role is true and no role claims returned", fields: fields{ - roleAttributeStrict: true, + SocialBase: newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{RoleAttributeStrict: true}, ""), }, claims: &azureClaims{ Email: "me@example.com", @@ -313,13 +378,16 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { wantErr: true, }, } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { s := &SocialAzureAD{ - SocialBase: tt.fields.SocialBase, - allowedGroups: tt.fields.allowedGroups, - autoAssignOrgRole: tt.fields.autoAssignOrgRole, - roleAttributeStrict: tt.fields.roleAttributeStrict, + SocialBase: tt.fields.SocialBase, + allowedGroups: tt.fields.allowedGroups, + } + + if tt.fields.SocialBase == nil { + s.SocialBase = newSocialBase("azuread", &oauth2.Config{}, &OAuthInfo{}, "") } key := []byte("secret") @@ -357,14 +425,10 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { } } raw, err = jwt.Signed(sig).Claims(cl).Claims(tt.claims).CompactSerialize() - if err != nil { - t.Error(err) - } + require.NoError(t, err) } else { raw, err = jwt.Signed(sig).Claims(cl).CompactSerialize() - if err != nil { - t.Error(err) - } + require.NoError(t, err) } token := &oauth2.Token{ @@ -383,9 +447,8 @@ func TestSocialAzureAD_UserInfo(t *testing.T) { t.Errorf("UserInfo() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("UserInfo() got = %v, want %v", got, tt.want) - } + + require.EqualValues(t, tt.want, got) }) } } diff --git a/pkg/login/social/errors.go b/pkg/login/social/errors.go new file mode 100644 index 00000000000..2624050a717 --- /dev/null +++ b/pkg/login/social/errors.go @@ -0,0 +1,9 @@ +package social + +import "errors" + +var ( + ErrIDTokenNotFound = errors.New("id_token not found") + ErrInvalidBasicRole = errors.New("user does not have a valid basic role") + ErrEmailNotFound = errors.New("error getting user info: no email found in access token") +) diff --git a/pkg/login/social/generic_oauth.go b/pkg/login/social/generic_oauth.go index 40701df138b..ef76b4ce619 100644 --- a/pkg/login/social/generic_oauth.go +++ b/pkg/login/social/generic_oauth.go @@ -26,8 +26,6 @@ type SocialGenericOAuth struct { emailAttributePath string loginAttributePath string nameAttributePath string - roleAttributePath string - roleAttributeStrict bool groupsAttributePath string idTokenAttributeName string teamIdsAttributePath string @@ -146,12 +144,14 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) } if userInfo.Role == "" { - role, err := s.extractRole(data) - if err != nil { - s.log.Warn("Failed to extract role", "error", err) - } else if role != "" { + role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, []string{}, true) + if role != "" { s.log.Debug("Setting user info role from extracted role") + userInfo.Role = role + if s.allowAssignGrafanaAdmin { + userInfo.IsGrafanaAdmin = &grafanaAdmin + } } } @@ -166,6 +166,10 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) } } + if s.roleAttributeStrict && !userInfo.Role.IsValid() { + return nil, ErrInvalidBasicRole + } + if userInfo.Email == "" { var err error userInfo.Email, err = s.FetchPrivateEmail(client) @@ -180,10 +184,6 @@ func (s *SocialGenericOAuth) UserInfo(client *http.Client, token *oauth2.Token) userInfo.Login = userInfo.Email } - if s.roleAttributeStrict && !models.RoleType(userInfo.Role).IsValid() { - return nil, errors.New("invalid role") - } - if !s.IsTeamMember(client) { return nil, errors.New("user not a member of one of the required teams") } @@ -354,19 +354,6 @@ func (s *SocialGenericOAuth) extractUserName(data *UserInfoJson) string { return "" } -func (s *SocialGenericOAuth) extractRole(data *UserInfoJson) (string, error) { - if s.roleAttributePath == "" { - return "", nil - } - - role, err := s.searchJSONForStringAttr(s.roleAttributePath, data.rawJSON) - - if err != nil { - return "", err - } - return role, nil -} - func (s *SocialGenericOAuth) extractGroups(data *UserInfoJson) ([]string, error) { if s.groupsAttributePath == "" { return []string{}, nil diff --git a/pkg/login/social/generic_oauth_test.go b/pkg/login/social/generic_oauth_test.go index bb7426f9f86..c5240587e18 100644 --- a/pkg/login/social/generic_oauth_test.go +++ b/pkg/login/social/generic_oauth_test.go @@ -13,6 +13,7 @@ import ( "github.com/go-kit/log/level" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" ) func newLogger(name string, lev string) log.Logger { @@ -245,12 +246,14 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) { } tests := []struct { - Name string - ResponseBody interface{} - OAuth2Extra interface{} - RoleAttributePath string - ExpectedEmail string - ExpectedRole string + Name string + AllowAssignGrafanaAdmin bool + ResponseBody interface{} + OAuth2Extra interface{} + RoleAttributePath string + ExpectedEmail string + ExpectedRole models.RoleType + ExpectedGrafanaAdmin *bool }{ { Name: "Given a valid id_token, a valid role path, no API response, use id_token", @@ -330,6 +333,38 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) { ExpectedEmail: "john.doe@example.com", ExpectedRole: "Admin", }, + { + Name: "Given a valid id_token and AssignGrafanaAdmin is unchecked, don't grant Server Admin", + AllowAssignGrafanaAdmin: false, + OAuth2Extra: map[string]interface{}{ + // { "role": "GrafanaAdmin", "email": "john.doe@example.com" } + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiR3JhZmFuYUFkbWluIiwiZW1haWwiOiJqb2huLmRvZUBleGFtcGxlLmNvbSJ9.cQqMJpVjwdtJ8qEZLOo9RKNbAFfpkQcpnRG0nopmWEI", + }, + ResponseBody: map[string]interface{}{ + "role": "FromResponse", + "email": "from_response@example.com", + }, + RoleAttributePath: "role", + ExpectedEmail: "john.doe@example.com", + ExpectedRole: "Admin", + ExpectedGrafanaAdmin: nil, + }, + { + Name: "Given a valid id_token and AssignGrafanaAdmin is checked, grant Server Admin", + AllowAssignGrafanaAdmin: true, + OAuth2Extra: map[string]interface{}{ + // { "role": "GrafanaAdmin", "email": "john.doe@example.com" } + "id_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiR3JhZmFuYUFkbWluIiwiZW1haWwiOiJqb2huLmRvZUBleGFtcGxlLmNvbSJ9.cQqMJpVjwdtJ8qEZLOo9RKNbAFfpkQcpnRG0nopmWEI", + }, + ResponseBody: map[string]interface{}{ + "role": "FromResponse", + "email": "from_response@example.com", + }, + RoleAttributePath: "role", + ExpectedEmail: "john.doe@example.com", + ExpectedRole: "Admin", + ExpectedGrafanaAdmin: trueBoolPtr(), + }, { Name: "Given a valid id_token, an invalid role path, a valid API response, prefer id_token", OAuth2Extra: map[string]interface{}{ @@ -368,7 +403,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) { }, RoleAttributePath: "role", ExpectedEmail: "john.doe@example.com", - ExpectedRole: "FromResponse", + ExpectedRole: "Fromresponse", }, { Name: "Given a valid id_token, a valid advanced JMESPath role path, derive the role", @@ -416,6 +451,8 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) { for _, test := range tests { provider.roleAttributePath = test.RoleAttributePath + provider.allowAssignGrafanaAdmin = test.AllowAssignGrafanaAdmin + t.Run(test.Name, func(t *testing.T) { body, err := json.Marshal(test.ResponseBody) require.NoError(t, err) @@ -439,6 +476,7 @@ func TestUserInfoSearchesForEmailAndRole(t *testing.T) { require.Equal(t, test.ExpectedEmail, actualResult.Email) require.Equal(t, test.ExpectedEmail, actualResult.Login) require.Equal(t, test.ExpectedRole, actualResult.Role) + require.Equal(t, test.ExpectedGrafanaAdmin, actualResult.IsGrafanaAdmin) }) } }) diff --git a/pkg/login/social/github_oauth.go b/pkg/login/social/github_oauth.go index a44145b36e6..c36c95b1ea8 100644 --- a/pkg/login/social/github_oauth.go +++ b/pkg/login/social/github_oauth.go @@ -201,21 +201,24 @@ func (s *SocialGithub) UserInfo(client *http.Client, token *oauth2.Token) (*Basi teams := convertToGroupList(teamMemberships) - role, err := s.extractRole(response.Body, teams) - if err != nil { - s.log.Error("Failed to extract role", "error", err) - } + role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, teams, true) if s.roleAttributeStrict && !role.IsValid() { - return nil, errors.New("invalid role") + return nil, ErrInvalidBasicRole + } + + var isGrafanaAdmin *bool = nil + if s.allowAssignGrafanaAdmin { + isGrafanaAdmin = &grafanaAdmin } userInfo := &BasicUserInfo{ - Name: data.Login, - Login: data.Login, - Id: fmt.Sprintf("%d", data.Id), - Email: data.Email, - Role: string(role), - Groups: teams, + Name: data.Login, + Login: data.Login, + Id: fmt.Sprintf("%d", data.Id), + Email: data.Email, + Role: role, + Groups: teams, + IsGrafanaAdmin: isGrafanaAdmin, } if data.Name != "" { userInfo.Name = data.Name diff --git a/pkg/login/social/github_oauth_test.go b/pkg/login/social/github_oauth_test.go index 305fa125ccb..2b96ab2e508 100644 --- a/pkg/login/social/github_oauth_test.go +++ b/pkg/login/social/github_oauth_test.go @@ -127,13 +127,12 @@ func TestSocialGitHub_UserInfo(t *testing.T) { autoAssignOrgRole: "", roleAttributePath: "", want: &BasicUserInfo{ - Id: "1", - Name: "monalisa octocat", - Email: "octocat@github.com", - Login: "octocat", - Company: "", - Role: "", - Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Role: "", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, }, }, { @@ -143,13 +142,12 @@ func TestSocialGitHub_UserInfo(t *testing.T) { autoAssignOrgRole: "Editor", userTeamsRawJSON: testGHUserTeamsJSON, want: &BasicUserInfo{ - Id: "1", - Name: "monalisa octocat", - Email: "octocat@github.com", - Login: "octocat", - Company: "", - Role: "Admin", - Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Role: "Admin", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, }, }, { @@ -159,29 +157,27 @@ func TestSocialGitHub_UserInfo(t *testing.T) { autoAssignOrgRole: "Editor", userTeamsRawJSON: testGHUserTeamsJSON, want: &BasicUserInfo{ - Id: "1", - Name: "monalisa octocat", - Email: "octocat@github.com", - Login: "octocat", - Company: "", - Role: "Editor", - Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Role: "Editor", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, }, }, - { - name: "auto assign org role", + { // Case that's going to change with Grafana 10 + name: "No fallback to default org role (will change in Grafana 10)", roleAttributePath: "", userRawJSON: testGHUserJSON, autoAssignOrgRole: "Editor", userTeamsRawJSON: testGHUserTeamsJSON, want: &BasicUserInfo{ - Id: "1", - Name: "monalisa octocat", - Email: "octocat@github.com", - Login: "octocat", - Company: "", - Role: "Editor", - Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, + Id: "1", + Name: "monalisa octocat", + Email: "octocat@github.com", + Login: "octocat", + Role: "", + Groups: []string{"https://github.com/orgs/github/teams/justice-league", "@github/justice-league"}, }, }, } diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go index 326ac0b06c5..a9b7eb5d862 100644 --- a/pkg/login/social/gitlab_oauth.go +++ b/pkg/login/social/gitlab_oauth.go @@ -2,7 +2,6 @@ package social import ( "encoding/json" - "errors" "fmt" "net/http" "regexp" @@ -90,7 +89,7 @@ func (s *SocialGitlab) GetGroupsPage(client *http.Client, url string) ([]string, return fullPaths, next } -func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { +func (s *SocialGitlab) UserInfo(client *http.Client, _ *oauth2.Token) (*BasicUserInfo, error) { var data struct { Id int Username string @@ -114,21 +113,24 @@ func (s *SocialGitlab) UserInfo(client *http.Client, token *oauth2.Token) (*Basi groups := s.GetGroups(client) - role, err := s.extractRole(response.Body, groups) - if err != nil { - s.log.Error("Failed to extract role", "error", err) - } + role, grafanaAdmin := s.extractRoleAndAdmin(response.Body, groups, true) if s.roleAttributeStrict && !role.IsValid() { - return nil, errors.New("invalid role") + return nil, ErrInvalidBasicRole + } + + var isGrafanaAdmin *bool = nil + if s.allowAssignGrafanaAdmin { + isGrafanaAdmin = &grafanaAdmin } userInfo := &BasicUserInfo{ - Id: fmt.Sprintf("%d", data.Id), - Name: data.Name, - Login: data.Username, - Email: data.Email, - Groups: groups, - Role: string(role), + Id: fmt.Sprintf("%d", data.Id), + Name: data.Name, + Login: data.Username, + Email: data.Email, + Groups: groups, + Role: role, + IsGrafanaAdmin: isGrafanaAdmin, } if !s.IsGroupMember(groups) { diff --git a/pkg/login/social/gitlab_oauth_test.go b/pkg/login/social/gitlab_oauth_test.go new file mode 100644 index 00000000000..29570078c58 --- /dev/null +++ b/pkg/login/social/gitlab_oauth_test.go @@ -0,0 +1,159 @@ +package social + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/grafana/grafana/pkg/models" + "github.com/stretchr/testify/require" +) + +const ( + apiURI = "/api/v4" + userURI = "/api/v4/user" + groupsURI = "/api/v4/groups" + + gitlabAttrPath = `is_admin && 'GrafanaAdmin' || contains(groups[*], 'admins') && 'Admin' || contains(groups[*], 'editors') && 'Editor' || contains(groups[*], 'viewers') && 'Viewer'` + + rootUserRespBody = `{"id":1,"username":"root","name":"Administrator","state":"active","email":"root@example.org","is_admin":true,"namespace_id":1}` + editorUserRespBody = `{"id":3,"username":"gitlab-editor","name":"Gitlab Editor","state":"active","email":"gitlab-editor@example.org","is_admin":false,"namespace_id":1}` + + adminGroup = `{"id":4,"web_url":"http://grafana-gitlab.local/groups/admins","name":"Admins","path":"admins","project_creation_level":"developer","full_name":"Admins","full_path":"admins","created_at":"2022-09-13T19:38:04.891Z"}` + editorGroup = `{"id":5,"web_url":"http://grafana-gitlab.local/groups/editors","name":"Editors","path":"editors","project_creation_level":"developer","full_name":"Editors","full_path":"editors","created_at":"2022-09-13T19:38:15.074Z"}` + viewerGroup = `{"id":6,"web_url":"http://grafana-gitlab.local/groups/viewers","name":"Viewers","path":"viewers","project_creation_level":"developer","full_name":"Viewers","full_path":"viewers","created_at":"2022-09-13T19:38:25.777Z"}` + // serverAdminGroup = `{"id":7,"web_url":"http://grafana-gitlab.local/groups/serveradmins","name":"ServerAdmins","path":"serveradmins","project_creation_level":"developer","full_name":"ServerAdmins","full_path":"serveradmins","created_at":"2022-09-13T19:38:36.227Z"}` +) + +func TestSocialGitlab_UserInfo(t *testing.T) { + provider := SocialGitlab{ + SocialBase: &SocialBase{ + log: newLogger("gitlab_oauth_test", "debug"), + }, + } + + type conf struct { + AllowAssignGrafanaAdmin bool + RoleAttributeStrict bool + AutoAssignOrgRole models.RoleType + } + + tests := []struct { + Name string + Cfg conf + UserRespBody string + GroupsRespBody string + RoleAttributePath string + ExpectedLogin string + ExpectedEmail string + ExpectedRole models.RoleType + ExpectedGrafanaAdmin *bool + ExpectedError error + }{ + { + Name: "Server Admin Allowed", + Cfg: conf{AllowAssignGrafanaAdmin: true}, + UserRespBody: rootUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{adminGroup, editorGroup, viewerGroup}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "root", + ExpectedEmail: "root@example.org", + ExpectedRole: "Admin", + ExpectedGrafanaAdmin: trueBoolPtr(), + }, + { // Edge case, user in Viewer Group, Server Admin disabled but attribute path contains a condition for Server Admin => User has the Admin role + Name: "Server Admin Disabled", + Cfg: conf{AllowAssignGrafanaAdmin: false}, + UserRespBody: rootUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{viewerGroup}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "root", + ExpectedEmail: "root@example.org", + ExpectedRole: "Admin", + }, + { + Name: "Editor", + Cfg: conf{AllowAssignGrafanaAdmin: true}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{viewerGroup, editorGroup}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "gitlab-editor", + ExpectedEmail: "gitlab-editor@example.org", + ExpectedRole: "Editor", + ExpectedGrafanaAdmin: falseBoolPtr(), + }, + { // Case that's going to change with Grafana 10 + Name: "No fallback to default org role (will change in Grafana 10)", + Cfg: conf{AutoAssignOrgRole: models.ROLE_VIEWER}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "gitlab-editor", + ExpectedEmail: "gitlab-editor@example.org", + ExpectedRole: "", + }, + { + Name: "Strict mode prevents fallback to default", + Cfg: conf{RoleAttributeStrict: true, AutoAssignOrgRole: models.ROLE_VIEWER}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedError: ErrInvalidBasicRole, + }, + { // Edge case, no match, no strict mode and no fallback => User has an empty role + Name: "Fallback with no default will create a user with an empty role", + Cfg: conf{}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{}, ",") + "]", + RoleAttributePath: gitlabAttrPath, + ExpectedLogin: "gitlab-editor", + ExpectedEmail: "gitlab-editor@example.org", + ExpectedRole: "", + }, + { // Edge case, no attribute path with strict mode => User has an empty role + Name: "Strict mode with no attribute path", + Cfg: conf{RoleAttributeStrict: true, AutoAssignOrgRole: models.ROLE_VIEWER}, + UserRespBody: editorUserRespBody, + GroupsRespBody: "[" + strings.Join([]string{editorGroup}, ",") + "]", + RoleAttributePath: "", + ExpectedError: ErrInvalidBasicRole, + }, + } + + for _, test := range tests { + provider.roleAttributePath = test.RoleAttributePath + provider.allowAssignGrafanaAdmin = test.Cfg.AllowAssignGrafanaAdmin + provider.autoAssignOrgRole = string(test.Cfg.AutoAssignOrgRole) + provider.roleAttributeStrict = test.Cfg.RoleAttributeStrict + + t.Run(test.Name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + switch r.RequestURI { + case userURI: + _, err := w.Write([]byte(test.UserRespBody)) + require.NoError(t, err) + case groupsURI: + _, err := w.Write([]byte(test.GroupsRespBody)) + require.NoError(t, err) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + provider.apiUrl = ts.URL + apiURI + actualResult, err := provider.UserInfo(ts.Client(), nil) + if test.ExpectedError != nil { + require.Equal(t, err, test.ExpectedError) + return + } + + require.NoError(t, err) + require.Equal(t, test.ExpectedEmail, actualResult.Email) + require.Equal(t, test.ExpectedLogin, actualResult.Login) + require.Equal(t, test.ExpectedRole, actualResult.Role) + require.Equal(t, test.ExpectedGrafanaAdmin, actualResult.IsGrafanaAdmin) + }) + } +} diff --git a/pkg/login/social/grafana_com_oauth.go b/pkg/login/social/grafana_com_oauth.go index 2757bc6aca0..28d7546a0f3 100644 --- a/pkg/login/social/grafana_com_oauth.go +++ b/pkg/login/social/grafana_com_oauth.go @@ -44,7 +44,7 @@ func (s *SocialGrafanaCom) IsOrganizationMember(organizations []OrgRecord) bool return false } -func (s *SocialGrafanaCom) UserInfo(client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) { +func (s *SocialGrafanaCom) UserInfo(client *http.Client, _ *oauth2.Token) (*BasicUserInfo, error) { var data struct { Id int `json:"id"` Name string `json:"name"` @@ -69,7 +69,7 @@ func (s *SocialGrafanaCom) UserInfo(client *http.Client, token *oauth2.Token) (* Name: data.Name, Login: data.Login, Email: data.Email, - Role: data.Role, + Role: models.RoleType(data.Role), } if !s.IsOrganizationMember(data.Orgs) { diff --git a/pkg/login/social/okta_oauth.go b/pkg/login/social/okta_oauth.go index e8e4bfe1186..f70c7c8d0db 100644 --- a/pkg/login/social/okta_oauth.go +++ b/pkg/login/social/okta_oauth.go @@ -13,10 +13,8 @@ import ( type SocialOkta struct { *SocialBase - apiUrl string - allowedGroups []string - roleAttributePath string - roleAttributeStrict bool + apiUrl string + allowedGroups []string } type OktaUserInfoJson struct { @@ -77,26 +75,29 @@ func (s *SocialOkta) UserInfo(client *http.Client, token *oauth2.Token) (*BasicU return nil, err } - role, err := s.extractRole(&data) - if err != nil { - s.log.Error("Failed to extract role", "error", err) - } - if s.roleAttributeStrict && !models.RoleType(role).IsValid() { - return nil, errors.New("invalid role") - } - groups := s.GetGroups(&data) if !s.IsGroupMember(groups) { return nil, errMissingGroupMembership } + role, grafanaAdmin := s.extractRoleAndAdmin(data.rawJSON, groups, true) + if s.roleAttributeStrict && !role.IsValid() { + return nil, ErrInvalidBasicRole + } + + var isGrafanaAdmin *bool = nil + if s.allowAssignGrafanaAdmin { + isGrafanaAdmin = &grafanaAdmin + } + return &BasicUserInfo{ - Id: claims.ID, - Name: claims.Name, - Email: email, - Login: email, - Role: role, - Groups: groups, + Id: claims.ID, + Name: claims.Name, + Email: email, + Login: email, + Role: role, + IsGrafanaAdmin: isGrafanaAdmin, + Groups: groups, }, nil } @@ -119,18 +120,6 @@ func (s *SocialOkta) extractAPI(data *OktaUserInfoJson, client *http.Client) err return nil } -func (s *SocialOkta) extractRole(data *OktaUserInfoJson) (string, error) { - if s.roleAttributePath == "" { - return "", nil - } - - role, err := s.searchJSONForStringAttr(s.roleAttributePath, data.rawJSON) - if err != nil { - return "", err - } - return role, nil -} - func (s *SocialOkta) GetGroups(data *OktaUserInfoJson) []string { groups := make([]string, 0) if len(data.Groups) > 0 { diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go index d172c362a5a..6c56a263d45 100644 --- a/pkg/login/social/social.go +++ b/pkg/login/social/social.go @@ -12,6 +12,8 @@ import ( "context" "golang.org/x/oauth2" + "golang.org/x/text/cases" + "golang.org/x/text/language" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" @@ -31,28 +33,29 @@ type SocialService struct { } type OAuthInfo struct { - ClientId, ClientSecret string - Scopes []string - AuthUrl, TokenUrl string - Enabled bool - EmailAttributeName string - EmailAttributePath string - RoleAttributePath string - RoleAttributeStrict bool - GroupsAttributePath string - TeamIdsAttributePath string - AllowedDomains []string - HostedDomain string - ApiUrl string - TeamsUrl string - AllowSignup bool - Name string - Icon string - TlsClientCert string - TlsClientKey string - TlsClientCa string - TlsSkipVerify bool - UsePKCE bool + ClientId, ClientSecret string + Scopes []string + AuthUrl, TokenUrl string + Enabled bool + EmailAttributeName string + EmailAttributePath string + RoleAttributePath string + RoleAttributeStrict bool + GroupsAttributePath string + TeamIdsAttributePath string + AllowedDomains []string + AllowAssignGrafanaAdmin bool + HostedDomain string + ApiUrl string + TeamsUrl string + AllowSignup bool + Name string + Icon string + TlsClientCert string + TlsClientKey string + TlsClientCa string + TlsSkipVerify bool + UsePKCE bool } func ProvideService(cfg *setting.Cfg) *SocialService { @@ -66,30 +69,31 @@ func ProvideService(cfg *setting.Cfg) *SocialService { sec := cfg.Raw.Section("auth." + name) info := &OAuthInfo{ - ClientId: sec.Key("client_id").String(), - ClientSecret: sec.Key("client_secret").String(), - Scopes: util.SplitString(sec.Key("scopes").String()), - AuthUrl: sec.Key("auth_url").String(), - TokenUrl: sec.Key("token_url").String(), - ApiUrl: sec.Key("api_url").String(), - TeamsUrl: sec.Key("teams_url").String(), - Enabled: sec.Key("enabled").MustBool(), - EmailAttributeName: sec.Key("email_attribute_name").String(), - EmailAttributePath: sec.Key("email_attribute_path").String(), - RoleAttributePath: sec.Key("role_attribute_path").String(), - RoleAttributeStrict: sec.Key("role_attribute_strict").MustBool(), - GroupsAttributePath: sec.Key("groups_attribute_path").String(), - TeamIdsAttributePath: sec.Key("team_ids_attribute_path").String(), - AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), - HostedDomain: sec.Key("hosted_domain").String(), - AllowSignup: sec.Key("allow_sign_up").MustBool(), - Name: sec.Key("name").MustString(name), - Icon: sec.Key("icon").String(), - TlsClientCert: sec.Key("tls_client_cert").String(), - TlsClientKey: sec.Key("tls_client_key").String(), - TlsClientCa: sec.Key("tls_client_ca").String(), - TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(), - UsePKCE: sec.Key("use_pkce").MustBool(), + ClientId: sec.Key("client_id").String(), + ClientSecret: sec.Key("client_secret").String(), + Scopes: util.SplitString(sec.Key("scopes").String()), + AuthUrl: sec.Key("auth_url").String(), + TokenUrl: sec.Key("token_url").String(), + ApiUrl: sec.Key("api_url").String(), + TeamsUrl: sec.Key("teams_url").String(), + Enabled: sec.Key("enabled").MustBool(), + EmailAttributeName: sec.Key("email_attribute_name").String(), + EmailAttributePath: sec.Key("email_attribute_path").String(), + RoleAttributePath: sec.Key("role_attribute_path").String(), + RoleAttributeStrict: sec.Key("role_attribute_strict").MustBool(), + GroupsAttributePath: sec.Key("groups_attribute_path").String(), + TeamIdsAttributePath: sec.Key("team_ids_attribute_path").String(), + AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()), + HostedDomain: sec.Key("hosted_domain").String(), + AllowSignup: sec.Key("allow_sign_up").MustBool(), + Name: sec.Key("name").MustString(name), + Icon: sec.Key("icon").String(), + TlsClientCert: sec.Key("tls_client_cert").String(), + TlsClientKey: sec.Key("tls_client_key").String(), + TlsClientCa: sec.Key("tls_client_ca").String(), + TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(), + UsePKCE: sec.Key("use_pkce").MustBool(), + AllowAssignGrafanaAdmin: sec.Key("allow_assign_grafana_admin").MustBool(false), } // when empty_scopes parameter exists and is true, overwrite scope with empty value @@ -163,21 +167,17 @@ func ProvideService(cfg *setting.Cfg) *SocialService { // AzureAD. if name == "azuread" { ss.socialMap["azuread"] = &SocialAzureAD{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), - allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), - autoAssignOrgRole: cfg.AutoAssignOrgRole, - roleAttributeStrict: info.RoleAttributeStrict, + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), + allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), } } // Okta if name == "okta" { ss.socialMap["okta"] = &SocialOkta{ - SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), - apiUrl: info.ApiUrl, - allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), - roleAttributePath: info.RoleAttributePath, - roleAttributeStrict: info.RoleAttributeStrict, + SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole), + apiUrl: info.ApiUrl, + allowedGroups: util.SplitString(sec.Key("allowed_groups").String()), } } @@ -190,8 +190,6 @@ func ProvideService(cfg *setting.Cfg) *SocialService { emailAttributeName: info.EmailAttributeName, emailAttributePath: info.EmailAttributePath, nameAttributePath: sec.Key("name_attribute_path").String(), - roleAttributePath: info.RoleAttributePath, - roleAttributeStrict: info.RoleAttributeStrict, groupsAttributePath: info.GroupsAttributePath, loginAttributePath: sec.Key("login_attribute_path").String(), idTokenAttributeName: sec.Key("id_token_attribute_name").String(), @@ -226,18 +224,18 @@ func ProvideService(cfg *setting.Cfg) *SocialService { } type BasicUserInfo struct { - Id string - Name string - Email string - Login string - Company string - Role string - Groups []string + Id string + Name string + Email string + Login string + Role models.RoleType + IsGrafanaAdmin *bool // nil will avoid overriding user's set server admin setting + Groups []string } func (b *BasicUserInfo) String() string { - return fmt.Sprintf("Id: %s, Name: %s, Email: %s, Login: %s, Company: %s, Role: %s, Groups: %v", - b.Id, b.Name, b.Email, b.Login, b.Company, b.Role, b.Groups) + return fmt.Sprintf("Id: %s, Name: %s, Email: %s, Login: %s, Role: %s, Groups: %v", + b.Id, b.Name, b.Email, b.Login, b.Role, b.Groups) } type SocialConnector interface { @@ -254,9 +252,10 @@ type SocialConnector interface { type SocialBase struct { *oauth2.Config - log log.Logger - allowSignup bool - allowedDomains []string + log log.Logger + allowSignup bool + allowAssignGrafanaAdmin bool + allowedDomains []string roleAttributePath string roleAttributeStrict bool @@ -272,7 +271,8 @@ func (e Error) Error() string { } const ( - grafanaCom = "grafana_com" + grafanaCom = "grafana_com" + RoleGrafanaAdmin = "GrafanaAdmin" // For AzureAD for example this value cannot contain spaces ) var ( @@ -297,13 +297,14 @@ func newSocialBase(name string, logger := log.New("oauth." + name) return &SocialBase{ - Config: config, - log: logger, - allowSignup: info.AllowSignup, - allowedDomains: info.AllowedDomains, - autoAssignOrgRole: autoAssignOrgRole, - roleAttributePath: info.RoleAttributePath, - roleAttributeStrict: info.RoleAttributeStrict, + Config: config, + log: logger, + allowSignup: info.AllowSignup, + allowAssignGrafanaAdmin: info.AllowAssignGrafanaAdmin, + allowedDomains: info.AllowedDomains, + autoAssignOrgRole: autoAssignOrgRole, + roleAttributePath: info.RoleAttributePath, + roleAttributeStrict: info.RoleAttributeStrict, } } @@ -311,28 +312,56 @@ type groupStruct struct { Groups []string `json:"groups"` } -func (s *SocialBase) extractRole(rawJSON []byte, groups []string) (models.RoleType, error) { +func (s *SocialBase) extractRoleAndAdmin(rawJSON []byte, groups []string, legacy bool) (models.RoleType, bool) { if s.roleAttributePath == "" { - if s.autoAssignOrgRole != "" { - return models.RoleType(s.autoAssignOrgRole), nil - } - - return "", nil + return s.defaultRole(legacy), false } role, err := s.searchJSONForStringAttr(s.roleAttributePath, rawJSON) if err == nil && role != "" { - return models.RoleType(role), nil + return getRoleFromSearch(role) } if groupBytes, err := json.Marshal(groupStruct{groups}); err == nil { - if role, err := s.searchJSONForStringAttr( - s.roleAttributePath, groupBytes); err == nil && role != "" { - return models.RoleType(role), nil + role, err := s.searchJSONForStringAttr(s.roleAttributePath, groupBytes) + if err == nil && role != "" { + return getRoleFromSearch(role) } } - return "", nil + return s.defaultRole(legacy), false +} + +// defaultRole returns the default role for the user based on the autoAssignOrgRole setting +// if legacy is enabled "" is returned indicating the previous role assignment is used. +func (s *SocialBase) defaultRole(legacy bool) models.RoleType { + if s.roleAttributeStrict { + s.log.Debug("RoleAttributeStrict is set, returning no role.") + return "" + } + + if s.autoAssignOrgRole != "" && !legacy { + s.log.Debug("No role found, returning default.") + return models.RoleType(s.autoAssignOrgRole) + } + + if legacy { + s.log.Warn("No valid role found. Skipping role sync. " + + "In Grafana 10, this will result in the user being assigned the default role and overriding manual assignment. " + + "If role sync is not desired, set oauth_skip_org_role_update_sync to false") + } + + return "" +} + +// match grafana admin role and translate to org role and bool. +// treat the JSON search result to ensure correct casing. +func getRoleFromSearch(role string) (models.RoleType, bool) { + if strings.EqualFold(role, RoleGrafanaAdmin) { + return models.ROLE_ADMIN, true + } + + return models.RoleType(cases.Title(language.Und).String(role)), false } // GetOAuthProviders returns available oauth providers and if they're enabled or not diff --git a/public/app/features/admin/UserAdminPage.tsx b/public/app/features/admin/UserAdminPage.tsx index 14865464d1e..87c50df9c0e 100644 --- a/public/app/features/admin/UserAdminPage.tsx +++ b/public/app/features/admin/UserAdminPage.tsx @@ -4,6 +4,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { NavModel } from '@grafana/data'; import { featureEnabled } from '@grafana/runtime'; import { Page } from 'app/core/components/Page/Page'; +import config from 'app/core/config'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; @@ -40,6 +41,8 @@ interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> { error?: UserAdminError; } +const SyncedOAuthLabels: string[] = ['GitHub', 'GitLab', 'AzureAD', 'OAuth']; + export class UserAdminPage extends PureComponent { async componentDidMount() { const { match, loadAdminUserPage } = this.props; @@ -107,6 +110,13 @@ export class UserAdminPage extends PureComponent { const isLDAPUser = user && user.isExternal && user.authLabels && user.authLabels.includes('LDAP'); const canReadSessions = contextSrv.hasPermission(AccessControlAction.UsersAuthTokenList); const canReadLDAPStatus = contextSrv.hasPermission(AccessControlAction.LDAPStatusRead); + const isOAuthUserWithSkippableSync = + user?.isExternal && user?.authLabels?.some((r) => SyncedOAuthLabels.includes(r)); + const isSAMLUser = user?.isExternal && user?.authLabels?.includes('SAML'); + const isUserSynced = + (user?.isExternal && !(isOAuthUserWithSkippableSync || isSAMLUser)) || + (!config.auth.OAuthSkipOrgRoleUpdateSync && isOAuthUserWithSkippableSync) || + (!config.auth.SAMLSkipOrgRoleSync && isSAMLUser); return ( @@ -132,7 +142,7 @@ export class UserAdminPage extends PureComponent {