Auth: Implement the SSO Settings List endpoint (#80769)

* add list endpoint & initial tests

* add tests and ETag

* format service_test.go

* add list swagger param, generate openAPI, remove ETag, use RedactedPassword

* correct swagger param name

* Align tests to latest changes

* use setting.RedactedValue()

* add string assertion

* lint & require no error on res.Body.Close()

* add custom response type

---------

Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
colin-stuart
2024-01-19 14:39:09 -05:00
committed by GitHub
co-authored by Mihaly Gyongyosi
parent 40312c527b
commit e241188f00
9 changed files with 579 additions and 29 deletions
+26 -5
View File
@@ -44,9 +44,9 @@ func ProvideApi(
}
// generateFNVETag computes a FNV hash-based ETag for the SSOSettings struct
func generateFNVETag(SSOSettings *models.SSOSettings) (string, error) {
func generateFNVETag(input any) (string, error) {
hasher := fnv.New64()
data, err := json.Marshal(SSOSettings)
data, err := json.Marshal(input)
if err != nil {
return "", err
}
@@ -76,17 +76,28 @@ func (api *Api) RegisterAPIEndpoints() {
})
}
// swagger:route GET /v1/sso-settings sso_settings listAllProvidersSettings
//
// # List all SSO Settings entries
//
// You need to have a permission with action `settings:read` with scope `settings:auth.<provider>:*`.
//
// Responses:
// 200: listSSOSettingsResponse
// 400: badRequestError
// 401: unauthorisedError
// 403: forbiddenError
func (api *Api) listAllProvidersSettings(c *contextmodel.ReqContext) response.Response {
providers, err := api.getAuthorizedList(c.Req.Context(), c.SignedInUser)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to get providers", err)
return response.Error(http.StatusInternalServerError, "Failed to list all providers settings", err)
}
return response.JSON(http.StatusOK, providers)
}
func (api *Api) getAuthorizedList(ctx context.Context, identity identity.Requester) ([]*models.SSOSettings, error) {
allProviders, err := api.SSOSettingsService.List(ctx)
allProviders, err := api.SSOSettingsService.ListWithRedactedSecrets(ctx)
if err != nil {
return nil, err
}
@@ -212,8 +223,12 @@ func (api *Api) removeProviderSettings(c *contextmodel.ReqContext) response.Resp
return response.Empty(http.StatusNoContent)
}
// swagger:parameters listAllProvidersSettings
type ListAllProvidersSettingsParams struct {
}
// swagger:parameters getProviderSettings
type GetProviderSettingsWrapper struct {
type GetProviderSettingsParams struct {
// in:path
// required:true
Provider string `json:"key"`
@@ -236,6 +251,12 @@ type RemoveProviderSettingsParams struct {
Provider string `json:"key"`
}
// swagger:response listSSOSettingsResponse
type ListSSOSettingsResponse struct {
// in: body
Body []models.SSOSettings `json:"body"`
}
// swagger:response getSSOSettingsResponse
type GetSSOSettingsResponse struct {
// in: body
+169
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"testing"
"time"
@@ -367,6 +368,174 @@ func TestSSOSettingsAPI_GetForProvider(t *testing.T) {
}
}
func TestSSOSettingsAPI_List(t *testing.T) {
type TestCase struct {
desc string
action string
scope string
expectedResult []*models.SSOSettings
errFromService error
wantErr bool
expectedErrMessage string
expectedServiceCall bool
expectedStatusCode int
}
tests := []TestCase{
{
desc: "successfully lists SSO settings",
action: "settings:read",
scope: "settings:auth.azuread:*",
expectedResult: []*models.SSOSettings{
{
ID: "1",
Provider: "azuread",
Settings: make(map[string]interface{}),
Source: models.DB,
},
},
expectedServiceCall: true,
expectedStatusCode: http.StatusOK,
},
{
desc: "returns empty list when the user has the action but the scope doesn't match any of the providerss scope",
action: "settings:read",
scope: "settings:auth.saml:write",
expectedResult: []*models.SSOSettings{},
expectedServiceCall: true,
expectedStatusCode: http.StatusOK,
},
{
desc: "successfully lists SSO settings when scope contains wildcard",
action: "settings:read",
scope: "settings:*",
expectedResult: []*models.SSOSettings{
{
ID: "1",
Provider: "azuread",
Settings: make(map[string]interface{}),
Source: models.DB,
},
{
ID: "2",
Provider: "github",
Settings: make(map[string]interface{}),
Source: models.DB,
},
{
ID: "3",
Provider: "okta",
Settings: make(map[string]interface{}),
Source: models.System,
},
},
expectedServiceCall: true,
expectedStatusCode: http.StatusOK,
},
{
desc: "fails when action doesn't match",
action: "madeupaction:read",
scope: "madeupscope:*",
wantErr: true,
expectedErrMessage: "You'll need additional permissions to perform this action. Permissions needed: settings:read",
expectedResult: nil,
expectedServiceCall: false,
expectedStatusCode: http.StatusForbidden,
},
{
desc: "fails with internal server error when service returns an error",
action: "settings:read",
scope: "settings:auth.azuread:*",
errFromService: errors.New("something went wrong"),
expectedResult: nil,
wantErr: true,
expectedErrMessage: "Failed to list all providers settings",
expectedServiceCall: true,
expectedStatusCode: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
service := ssosettingstests.NewMockService(t)
serviceResult := []*models.SSOSettings{
{
ID: "1",
Provider: "azuread",
Settings: make(map[string]interface{}),
Created: time.Now(),
Updated: time.Now(),
IsDeleted: false,
Source: models.DB,
},
{
ID: "2",
Provider: "github",
Settings: make(map[string]interface{}),
Created: time.Now(),
Updated: time.Now(),
IsDeleted: false,
Source: models.DB,
},
{
ID: "3",
Provider: "okta",
Settings: make(map[string]interface{}),
Created: time.Now(),
Updated: time.Now(),
IsDeleted: false,
Source: models.System,
},
}
if tt.expectedServiceCall {
service.On("ListWithRedactedSecrets", mock.AnythingOfType("*context.valueCtx")).Return(serviceResult, tt.errFromService).Once()
}
server := setupTests(t, service)
path := "/api/v1/sso-settings"
req := server.NewRequest(http.MethodGet, path, nil)
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgRole: org.RoleEditor,
OrgID: 1,
Permissions: getPermissionsForActionAndScope(tt.action, tt.scope),
})
res, err := server.SendJSON(req)
require.NoError(t, err)
require.Equal(t, tt.expectedStatusCode, res.StatusCode)
bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
if tt.wantErr {
var accessErrorResponse struct {
AccessErrorID string `json:"accessErrorId"`
Message string `json:"message"`
Title string `json:"title"`
}
err = json.Unmarshal(bodyBytes, &accessErrorResponse)
if err != nil {
t.Fatalf("Failed to unmarshal response body into accessErrorResponse: %v", err)
}
require.Equal(t, tt.expectedErrMessage, accessErrorResponse.Message)
return
}
var actual []*models.SSOSettings
err = json.Unmarshal(bodyBytes, &actual)
require.NoError(t, err)
require.ElementsMatch(t, tt.expectedResult, actual)
err = res.Body.Close()
require.NoError(t, err)
})
}
}
func getPermissionsForActionAndScope(action, scope string) map[int64]map[string][]string {
return map[int64]map[string][]string{
1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{{
+2
View File
@@ -21,6 +21,8 @@ var (
type Service interface {
// List returns all SSO settings from DB and config files
List(ctx context.Context) ([]*models.SSOSettings, error)
// ListWithRedactedSecrets returns all SSO settings from DB and config files with secret values redacted
ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSettings, error)
// GetForProvider returns the SSO settings for a given provider (DB or config file)
GetForProvider(ctx context.Context, provider string) (*models.SSOSettings, error)
// GetForProviderWithRedactedSecrets returns the SSO settings for a given provider (DB or config file) with secret values redacted
@@ -94,8 +94,8 @@ func (s *SSOSettingsService) GetForProviderWithRedactedSecrets(ctx context.Conte
}
for k, v := range storeSettings.Settings {
if isSecret(k) && v != "" {
storeSettings.Settings[k] = setting.RedactedPassword
if strVal, ok := v.(string); ok {
storeSettings.Settings[k] = setting.RedactedValue(k, strVal)
}
}
@@ -130,6 +130,23 @@ func (s *SSOSettingsService) List(ctx context.Context) ([]*models.SSOSettings, e
return result, nil
}
func (s *SSOSettingsService) ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSettings, error) {
storeSettings, err := s.List(ctx)
if err != nil {
return nil, err
}
for _, storeSetting := range storeSettings {
for k, v := range storeSetting.Settings {
if strVal, ok := v.(string); ok {
storeSetting.Settings[k] = setting.RedactedValue(k, strVal)
}
}
}
return storeSettings, nil
}
func (s *SSOSettingsService) Upsert(ctx context.Context, settings models.SSOSettings) error {
if !isProviderConfigurable(settings.Provider) {
return ssosettings.ErrInvalidProvider.Errorf("provider %s is not configurable", settings.Provider)
@@ -414,6 +414,190 @@ func TestSSOSettingsService_List(t *testing.T) {
},
wantErr: false,
},
{
name: "should return error if any of the fallback strategies was not found",
setup: func(env testEnv) {
env.store.ExpectedSSOSettings = []*models.SSOSettings{}
env.fallbackStrategy.ExpectedIsMatch = false
},
want: nil,
wantErr: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
env := setupTestEnv(t)
if tc.setup != nil {
tc.setup(env)
}
actual, err := env.service.List(context.Background())
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.ElementsMatch(t, tc.want, actual)
})
}
}
func TestSSOSettingsService_ListWithRedactedSecrets(t *testing.T) {
testCases := []struct {
name string
setup func(env testEnv)
want []*models.SSOSettings
wantErr bool
}{
{
name: "should return successfully",
setup: func(env testEnv) {
env.store.ExpectedSSOSettings = []*models.SSOSettings{
{
Provider: "github",
Settings: map[string]any{
"enabled": true,
"client_secret": base64.RawStdEncoding.EncodeToString([]byte("client_secret")),
"client_id": "client_id",
},
Source: models.DB,
},
{
Provider: "okta",
Settings: map[string]any{
"enabled": false,
"other_secret": base64.RawStdEncoding.EncodeToString([]byte("other_secret")),
"client_id": "client_id",
},
Source: models.DB,
},
}
env.secrets.On("Decrypt", mock.Anything, []byte("client_secret"), mock.Anything).Return([]byte("decrypted-client-secret"), nil).Once()
env.secrets.On("Decrypt", mock.Anything, []byte("other_secret"), mock.Anything).Return([]byte("decrypted-other-secret"), nil).Once()
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{
"github": {
"enabled": true,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"okta": {
"enabled": true,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"gitlab": {
"enabled": true,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"generic_oauth": {
"enabled": true,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"google": {
"enabled": true,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"azuread": {
"enabled": true,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"grafana_com": {
"enabled": true,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
}
},
want: []*models.SSOSettings{
{
Provider: "github",
Settings: map[string]any{
"enabled": true,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.DB,
},
{
Provider: "okta",
Settings: map[string]any{
"enabled": false,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
"other_secret": "*********",
},
Source: models.DB,
},
{
Provider: "gitlab",
Settings: map[string]any{
"enabled": true,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "generic_oauth",
Settings: map[string]any{
"enabled": true,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "google",
Settings: map[string]any{
"enabled": true,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "azuread",
Settings: map[string]any{
"enabled": true,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "grafana_com",
Settings: map[string]any{
"enabled": true,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
},
wantErr: false,
},
{
name: "should return error if store returns an error",
setup: func(env testEnv) { env.store.ExpectedError = fmt.Errorf("error") },
@@ -427,63 +611,119 @@ func TestSSOSettingsService_List(t *testing.T) {
env.fallbackStrategy.ExpectedIsMatch = true
env.fallbackStrategy.ExpectedConfigs = map[string]map[string]any{
"github": {
"enabled": false,
"enabled": false,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"okta": {
"enabled": false,
"enabled": false,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"gitlab": {
"enabled": false,
"enabled": false,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"generic_oauth": {
"enabled": false,
"enabled": false,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"google": {
"enabled": false,
"enabled": false,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"azuread": {
"enabled": false,
"enabled": false,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
"grafana_com": {
"enabled": false,
"enabled": false,
"secret": "secret",
"client_secret": "client_secret",
"client_id": "client_id",
},
}
},
want: []*models.SSOSettings{
{
Provider: "github",
Settings: map[string]any{"enabled": false},
Source: models.System,
Settings: map[string]any{
"enabled": false,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "okta",
Settings: map[string]any{"enabled": false},
Source: models.System,
Settings: map[string]any{
"enabled": false,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "gitlab",
Settings: map[string]any{"enabled": false},
Source: models.System,
Settings: map[string]any{
"enabled": false,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "generic_oauth",
Settings: map[string]any{"enabled": false},
Source: models.System,
Settings: map[string]any{
"enabled": false,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "google",
Settings: map[string]any{"enabled": false},
Source: models.System,
Settings: map[string]any{
"enabled": false,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "azuread",
Settings: map[string]any{"enabled": false},
Source: models.System,
Settings: map[string]any{
"enabled": false,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
{
Provider: "grafana_com",
Settings: map[string]any{"enabled": false},
Source: models.System,
Settings: map[string]any{
"enabled": false,
"secret": "*********",
"client_secret": "*********",
"client_id": "client_id",
},
Source: models.System,
},
},
wantErr: false,
@@ -505,7 +745,7 @@ func TestSSOSettingsService_List(t *testing.T) {
tc.setup(env)
}
actual, err := env.service.List(context.Background())
actual, err := env.service.ListWithRedactedSecrets(context.Background())
if tc.wantErr {
require.Error(t, err)
@@ -124,6 +124,36 @@ func (_m *MockService) List(ctx context.Context) ([]*models.SSOSettings, error)
return r0, r1
}
// ListWithRedactedSecrets provides a mock function with given fields: ctx
func (_m *MockService) ListWithRedactedSecrets(ctx context.Context) ([]*models.SSOSettings, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for ListWithRedactedSecrets")
}
var r0 []*models.SSOSettings
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]*models.SSOSettings, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []*models.SSOSettings); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.SSOSettings)
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Patch provides a mock function with given fields: ctx, provider, data
func (_m *MockService) Patch(ctx context.Context, provider string, data map[string]interface{}) error {
ret := _m.Called(ctx, provider, data)
+1
View File
@@ -602,6 +602,7 @@ func RedactedValue(key, value string) string {
"ACCOUNT_KEY",
"ENCRYPTION_KEY",
"VAULT_TOKEN",
"CLIENT_SECRET",
} {
if match, err := regexp.MatchString(pattern, uppercased); match && err == nil {
return RedactedPassword
+33
View File
@@ -11383,6 +11383,30 @@
}
}
},
"/v1/sso-settings": {
"get": {
"description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.",
"tags": [
"sso_settings"
],
"summary": "List all SSO Settings entries",
"operationId": "listAllProvidersSettings",
"responses": {
"200": {
"$ref": "#/responses/listSSOSettingsResponse"
},
"400": {
"$ref": "#/responses/badRequestError"
},
"401": {
"$ref": "#/responses/unauthorisedError"
},
"403": {
"$ref": "#/responses/forbiddenError"
}
}
}
},
"/v1/sso-settings/{key}": {
"get": {
"description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.",
@@ -23124,6 +23148,15 @@
}
}
},
"listSSOSettingsResponse": {
"description": "(empty)",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/SSOSettings"
}
}
},
"listSortOptionsResponse": {
"description": "(empty)",
"schema": {
+37
View File
@@ -1433,6 +1433,19 @@
},
"description": "(empty)"
},
"listSSOSettingsResponse": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/SSOSettings"
},
"type": "array"
}
}
},
"description": "(empty)"
},
"listSortOptionsResponse": {
"content": {
"application/json": {
@@ -25053,6 +25066,30 @@
]
}
},
"/v1/sso-settings": {
"get": {
"description": "You need to have a permission with action `settings:read` with scope `settings:auth.\u003cprovider\u003e:*`.",
"operationId": "listAllProvidersSettings",
"responses": {
"200": {
"$ref": "#/components/responses/listSSOSettingsResponse"
},
"400": {
"$ref": "#/components/responses/badRequestError"
},
"401": {
"$ref": "#/components/responses/unauthorisedError"
},
"403": {
"$ref": "#/components/responses/forbiddenError"
}
},
"summary": "List all SSO Settings entries",
"tags": [
"sso_settings"
]
}
},
"/v1/sso-settings/{key}": {
"delete": {
"description": "Removes the SSO Settings for a provider.\n\nYou need to have a permission with action `settings:write` and scope `settings:auth.\u003cprovider\u003e:*`.",