Auth: Implement the SSO Settings GET endpoint (#79144)
* Return data in camelCase from the OAuth fb strategy * changes * wip * Add defaults for oauth fb strategy * revert other changes * basic includeDefaults query param implementation * basic secret removal and etag implementation * correct imports * rebase * move default settings filter to models * only replace ClientSecret value if set * first GetForProvider test & use FNV for ETag to avoid Blocklisted import error * add tests * add annotation for the openapi spec & generate spec * remove TODO * use IsSecret, improve tests, remove DefaultOAuthSettings * add comment explaining generateFNVETag * add error handling for generateFNVETag * run go generate * Update pkg/services/ssosettings/api/api.go Co-authored-by: Mihai Doarna <mihai.doarna@grafana.com> * move isSecret to service, create GetForProviderWithRedactedSecrets func * add unit test for GetForProviderWithRedactedSecrets & remove duplicated code * regen openapi/swagger * revert dependency bumps --------- Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com> Co-authored-by: Mihai Doarna <mihai.doarna@grafana.com>
This commit is contained in:
co-authored by
Mihai Doarna
Mihaly Gyongyosi
parent
505196bcd5
commit
062e772bb2
@@ -2,7 +2,10 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
@@ -40,6 +43,22 @@ func ProvideApi(
|
||||
return api
|
||||
}
|
||||
|
||||
// generateFNVETag computes a FNV hash-based ETag for the SSOSettings struct
|
||||
func generateFNVETag(SSOSettings *models.SSOSettings) (string, error) {
|
||||
hasher := fnv.New64()
|
||||
data, err := json.Marshal(SSOSettings)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, err = hasher.Write(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// RegisterAPIEndpoints Registers Endpoints on Grafana Router
|
||||
func (api *Api) RegisterAPIEndpoints() {
|
||||
api.RouteRegister.Group("/api/v1/sso-settings", func(router routing.RouteRegister) {
|
||||
@@ -91,18 +110,39 @@ func (api *Api) getAuthorizedList(ctx context.Context, identity identity.Request
|
||||
return authorizedProviders, nil
|
||||
}
|
||||
|
||||
// swagger:route GET /v1/sso-settings/{key} sso_settings getProviderSettings
|
||||
//
|
||||
// # Get an SSO Settings entry by Key
|
||||
//
|
||||
// You need to have a permission with action `settings:read` with scope `settings:auth.<provider>:*`.
|
||||
//
|
||||
// Responses:
|
||||
// 200: okResponse
|
||||
// 400: badRequestError
|
||||
// 401: unauthorisedError
|
||||
// 403: forbiddenError
|
||||
// 404: notFoundError
|
||||
func (api *Api) getProviderSettings(c *contextmodel.ReqContext) response.Response {
|
||||
key, ok := web.Params(c.Req)[":key"]
|
||||
if !ok {
|
||||
return response.Error(http.StatusBadRequest, "Missing key", nil)
|
||||
}
|
||||
|
||||
settings, err := api.SSOSettingsService.GetForProvider(c.Req.Context(), key)
|
||||
provider, err := api.SSOSettingsService.GetForProviderWithRedactedSecrets(c.Req.Context(), key)
|
||||
|
||||
if err != nil {
|
||||
return response.Error(http.StatusNotFound, "The provider was not found", err)
|
||||
if errors.Is(err, ssosettings.ErrNotFound) {
|
||||
return response.Error(http.StatusNotFound, "The provider was not found", err)
|
||||
}
|
||||
return response.Error(http.StatusInternalServerError, "Failed to get provider settings", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, settings)
|
||||
etag, err := generateFNVETag(provider)
|
||||
if err != nil {
|
||||
return response.Error(http.StatusInternalServerError, "Failed to get provider settings", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, provider).SetHeader("ETag", etag)
|
||||
}
|
||||
|
||||
// swagger:route PUT /v1/sso-settings/{key} sso_settings updateProviderSettings
|
||||
@@ -172,6 +212,13 @@ func (api *Api) removeProviderSettings(c *contextmodel.ReqContext) response.Resp
|
||||
return response.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// swagger:parameters getProviderSettings
|
||||
type GetProviderSettingsWrapper struct {
|
||||
// in:path
|
||||
// required:true
|
||||
Provider string `json:"key"`
|
||||
}
|
||||
|
||||
// swagger:parameters updateProviderSettings
|
||||
type UpdateProviderSettingsParams struct {
|
||||
// in:path
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -253,6 +254,119 @@ func TestSSOSettingsAPI_Delete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSOSettingsAPI_GetForProvider(t *testing.T) {
|
||||
type TestCase struct {
|
||||
desc string
|
||||
key string
|
||||
action string
|
||||
scope string
|
||||
expectedResult *models.SSOSettings
|
||||
expectedError error
|
||||
expectedServiceCall bool
|
||||
expectedStatusCode int
|
||||
}
|
||||
|
||||
tests := []TestCase{
|
||||
{
|
||||
desc: "successfully gets SSO settings",
|
||||
key: "azuread",
|
||||
action: "settings:read",
|
||||
scope: "settings:auth.azuread:*",
|
||||
expectedResult: &models.SSOSettings{
|
||||
ID: "1",
|
||||
Provider: "azuread",
|
||||
Settings: make(map[string]interface{}),
|
||||
Created: time.Now(),
|
||||
Updated: time.Now(),
|
||||
IsDeleted: false,
|
||||
Source: models.DB,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedServiceCall: true,
|
||||
expectedStatusCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
desc: "fails when action doesn't match",
|
||||
key: "azuread",
|
||||
action: "settings:write",
|
||||
scope: "settings:auth.azuread:*",
|
||||
expectedResult: nil,
|
||||
expectedError: nil,
|
||||
expectedServiceCall: false,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
desc: "fails when scope doesn't match",
|
||||
key: "azuread",
|
||||
action: "settings:read",
|
||||
scope: "settings:auth.azuread:write",
|
||||
expectedResult: nil,
|
||||
expectedError: nil,
|
||||
expectedServiceCall: false,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
desc: "fails when scope contains another provider",
|
||||
key: "azuread",
|
||||
action: "settings:read",
|
||||
scope: "settings:auth.github:*",
|
||||
expectedResult: nil,
|
||||
expectedError: nil,
|
||||
expectedServiceCall: false,
|
||||
expectedStatusCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
desc: "fails with not found when key was not found",
|
||||
key: "nonexistant",
|
||||
action: "settings:read",
|
||||
scope: "settings:auth.nonexistant:*",
|
||||
expectedResult: nil,
|
||||
expectedError: ssosettings.ErrNotFound,
|
||||
expectedServiceCall: true,
|
||||
expectedStatusCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
desc: "fails with internal server error when service returns an error",
|
||||
key: "azuread",
|
||||
action: "settings:read",
|
||||
scope: "settings:auth.azuread:*",
|
||||
expectedResult: nil,
|
||||
expectedError: errors.New("something went wrong"),
|
||||
expectedServiceCall: true,
|
||||
expectedStatusCode: http.StatusInternalServerError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.desc, func(t *testing.T) {
|
||||
service := ssosettingstests.NewMockService(t)
|
||||
if tt.expectedServiceCall {
|
||||
service.On("GetForProviderWithRedactedSecrets", mock.AnythingOfType("*context.valueCtx"), tt.key).Return(tt.expectedResult, tt.expectedError).Once()
|
||||
}
|
||||
server := setupTests(t, service)
|
||||
|
||||
path := fmt.Sprintf("/api/v1/sso-settings/%s", tt.key)
|
||||
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)
|
||||
|
||||
if tt.expectedError == nil {
|
||||
var data models.SSOSettings
|
||||
require.NoError(t, json.NewDecoder(res.Body).Decode(&data))
|
||||
}
|
||||
|
||||
require.NoError(t, res.Body.Close())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getPermissionsForActionAndScope(action, scope string) map[int64]map[string][]string {
|
||||
return map[int64]map[string][]string{
|
||||
1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{{
|
||||
|
||||
Reference in New Issue
Block a user