CloudMigrations: Introduce RBAC role for migration assistant (#98588)

* CloudMigrations: delete unused code

* CloudMigrations: add access control and protect API + navtree with action

* CloudMigrations: register access control roles

* CloudMigrations: gate frontend based with access control

* CloudMigrations: fix api tests

* CloudMigrations: add docs on new actions and roles

* CloudMigrations: dont interpolate vars to make it more greppable

* CloudMigrations: run prettier
This commit is contained in:
Matheus Macabu 2025-01-09 05:03:42 +01:00 committed by GitHub
parent 79d8201b49
commit 3958fb9e0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 188 additions and 164 deletions

View File

@ -114,6 +114,7 @@ The following list contains role-based access control actions.
| `licensing:delete` | None | Delete the license token. |
| `licensing:read` | None | Read licensing information. |
| `licensing:write` | None | Update the license token. |
| `migrationassistant:migrate` | None | Execute on-prem to cloud migrations through the Migration Assistant. |
| `org.users:write` | <ul><li>`users:*`</li><li>`users:id:*`</li></ul> | Update the organization role (`None`, `Viewer`, `Editor`, or `Admin`) of a user. |
| `org.users:add` | <ul><li>`users:*`</li><li>`users:id:*`</li></ul> | Add a user to an organization or invite a new user to an organization. |
| `org.users:read` | <ul><li>`users:*`</li><li>`users:id:*`</li></ul> | Get user profiles within an organization. |

View File

@ -56,7 +56,7 @@ The following tables list permissions associated with basic and fixed roles.
| Basic role | UID | Associated fixed roles | Description |
| ------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Grafana Admin | `basic_grafana_admin` | `fixed:roles:reader`<br>`fixed:roles:writer`<br>`fixed:users:reader`<br>`fixed:users:writer`<br>`fixed:org.users:reader`<br>`fixed:org.users:writer`<br>`fixed:ldap:reader`<br>`fixed:ldap:writer`<br>`fixed:stats:reader`<br>`fixed:settings:reader`<br>`fixed:settings:writer`<br>`fixed:provisioning:writer`<br>`fixed:organization:reader`<br>`fixed:organization:maintainer`<br>`fixed:licensing:reader`<br>`fixed:licensing:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:maintainer`<br>`fixed:authentication.config:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:writer`<br>`fixed:library.panels:general.writer`<br>`fixed:groupsync:writer` | Default [Grafana server administrator](/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/#grafana-server-administrators) assignments. |
| Grafana Admin | `basic_grafana_admin` | `fixed:roles:reader`<br>`fixed:roles:writer`<br>`fixed:users:reader`<br>`fixed:users:writer`<br>`fixed:org.users:reader`<br>`fixed:org.users:writer`<br>`fixed:ldap:reader`<br>`fixed:ldap:writer`<br>`fixed:stats:reader`<br>`fixed:settings:reader`<br>`fixed:settings:writer`<br>`fixed:provisioning:writer`<br>`fixed:organization:reader`<br>`fixed:organization:maintainer`<br>`fixed:licensing:reader`<br>`fixed:licensing:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:maintainer`<br>`fixed:authentication.config:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:writer`<br>`fixed:library.panels:general.writer`<br>`fixed:groupsync:writer`<br>`fixed:migrationassistant:migrator` | Default [Grafana server administrator](/docs/grafana/<GRAFANA_VERSION>/administration/roles-and-permissions/#grafana-server-administrators) assignments. |
| Admin | `basic_admin` | `fixed:reports:reader`<br>`fixed:reports:writer`<br>`fixed:datasources:reader`<br>`fixed:datasources:writer`<br>`fixed:organization:writer`<br>`fixed:datasources.permissions:reader`<br>`fixed:datasources.permissions:writer`<br>`fixed:teams:writer`<br>`fixed:dashboards:reader`<br>`fixed:dashboards:writer`<br>`fixed:dashboards.permissions:reader`<br>`fixed:dashboards.permissions:writer`<br>`fixed:dashboards.public:writer`<br>`fixed:folders:reader`<br>`fixed:folders:writer`<br>`fixed:folders.permissions:reader`<br>`fixed:folders.permissions:writer`<br>`fixed:alerting:writer`<br>`fixed:apikeys:reader`<br>`fixed:apikeys:writer`<br>`fixed:alerting.provisioning.secrets:reader`<br>`fixed:alerting.provisioning:writer`<br>`fixed:datasources.caching:reader`<br>`fixed:datasources.caching:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:plugins:writer`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:writer`<br>`fixed:library.panels:general.writer`<br>`fixed:alerting.provisioning.status:writer`<br>`fixed:groupsync:writer` | Default [Grafana organization administrator](ref:rbac-basic-roles) assignments. |
| Editor | `basic_editor` | `fixed:datasources:explorer`<br>`fixed:dashboards:creator`<br>`fixed:folders:creator`<br>`fixed:annotations:writer`<br>`fixed:teams:creator` if the `editors_can_admin` configuration flag is enabled<br>`fixed:alerting:writer`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:library.panels:creator`<br>`fixed:library.panels:general.reader`<br>`fixed:library.panels:general.writer`<br>`fixed:alerting.provisioning.status:writer` | Default [Editor](ref:rbac-basic-roles) assignments. |
| Viewer | `basic_viewer` | `fixed:datasources.id:reader`<br>`fixed:organization:reader`<br>`fixed:annotations:reader`<br>`fixed:annotations.dashboard:writer`<br>`fixed:alerting:reader`<br>`fixed:plugins.app:reader`<br>`fixed:dashboards.insights:reader`<br>`fixed:datasources.insights:reader`<br>`fixed:library.panels:general.reader`<br>`fixed:datasources:explorer` if the `viewers_can_edit` configuration flag is enabled | Default [Viewer](ref:rbac-basic-roles) assignments. |
@ -125,6 +125,7 @@ To learn how to use the roles API to determine the role UUIDs, refer to [Manage
| `fixed:library.panels:writer` | `fixed_JTljAr21LWLTXCkgfBC4H0lhBC8` | All permissions from `fixed:library.panels:reader` plus<br>`library.panels:create`<br>`library.panels:delete`<br>`library.panels:write` | Create, read, write or delete all library panels and their permissions. |
| `fixed:licensing:reader` | `fixed_OADpuXvNEylO2Kelu3GIuBXEAYE` | `licensing:read`<br>`licensing.reports:read` | Read licensing information and licensing reports. |
| `fixed:licensing:writer` | `fixed_gzbz3rJpQMdaKHt-E4q0PVaKMoE` | All permissions from `fixed:licensing:viewer` and <br>`licensing:write`<br>`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. |
| `fixed:migrationassistant:migrator` | `fixed_LLk2p7TRuBztOAksTQb1Klc8YTk` | `migrationassistant:migrate` | Execute on-prem to cloud migrations through the Migration Assistant. |
| `fixed:org.users:reader` | `fixed_oCqNwlVHLOpw7-jAlwp4HzYqwGY` | `org.users:read` | Read users within a single organization. |
| `fixed:org.users:writer` | `fixed_VERj5nayasjgf_Yh0sWqqCkxWlw` | All permissions from `fixed:org.users:reader` and <br>`org.users:add`<br>`org.users:remove`<br>`org.users:write` | Within a single organization, add a user, invite a new user, read information about a user and their role, remove a user from that organization, or change the role of a user. |
| `fixed:organization:maintainer` | `fixed_CMm-uuBaPUBf4r8XG3jIvxo55bg` | All permissions from `fixed:organization:reader` and <br> `orgs:write`<br>`orgs:create`<br>`orgs:delete`<br>`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. This role needs to be assigned globally. |

View File

@ -42,6 +42,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/ssoutils"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/cloudmigration"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/correlations"
"github.com/grafana/grafana/pkg/services/dashboards"
@ -117,7 +118,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/admin/stats", authorize(ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index)
if hs.Features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) {
r.Get("/admin/migrate-to-cloud", reqOrgAdmin, hs.Index)
r.Get("/admin/migrate-to-cloud", authorize(cloudmigration.MigrationAssistantAccess), hs.Index)
}
// feature toggle admin page

View File

@ -0,0 +1,31 @@
package cloudmigration
import "github.com/grafana/grafana/pkg/services/accesscontrol"
const (
ActionMigrate = "migrationassistant:migrate"
)
var (
// MigrationAssistantAccess is used to protect the "Migrate to Grafana Cloud" page.
MigrationAssistantAccess = accesscontrol.EvalPermission(ActionMigrate)
)
func RegisterAccessControlRoles(service accesscontrol.Service) error {
migrator := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:migrationassistant:migrator",
DisplayName: "Organization resource migrator",
Description: "Migrate organization resources.",
Group: "Migration Assistant",
Permissions: []accesscontrol.Permission{
{
Action: ActionMigrate,
},
},
},
Grants: []string{string(accesscontrol.RoleGrafanaAdmin)},
}
return service.DeclareFixedRoles(migrator)
}

View File

@ -8,7 +8,7 @@ import (
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/cloudmigration"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/util"
@ -28,6 +28,7 @@ func RegisterApi(
rr routing.RouteRegister,
cms cloudmigration.Service,
tracer tracing.Tracer,
acHandler accesscontrol.AccessControl,
) *CloudMigrationAPI {
api := &CloudMigrationAPI{
log: log.New("cloudmigrations.api"),
@ -35,12 +36,14 @@ func RegisterApi(
cloudMigrationService: cms,
tracer: tracer,
}
api.registerEndpoints()
api.registerEndpoints(acHandler)
return api
}
// registerEndpoints Registers Endpoints on Grafana Router
func (cma *CloudMigrationAPI) registerEndpoints() {
func (cma *CloudMigrationAPI) registerEndpoints(acHandler accesscontrol.AccessControl) {
authorize := accesscontrol.Middleware(acHandler)
cma.routeRegister.Group("/api/cloudmigration", func(cloudMigrationRoute routing.RouteRegister) {
// destination instance endpoints for token management
cloudMigrationRoute.Get("/token", routing.Wrap(cma.GetToken))
@ -59,7 +62,7 @@ func (cma *CloudMigrationAPI) registerEndpoints() {
cloudMigrationRoute.Get("/migration/:uid/snapshots", routing.Wrap(cma.GetSnapshotList))
cloudMigrationRoute.Post("/migration/:uid/snapshot/:snapshotUid/upload", routing.Wrap(cma.UploadSnapshot))
cloudMigrationRoute.Post("/migration/:uid/snapshot/:snapshotUid/cancel", routing.Wrap(cma.CancelSnapshot))
}, middleware.ReqOrgAdmin)
}, authorize(cloudmigration.MigrationAssistantAccess))
}
// swagger:route GET /cloudmigration/token migrations getCloudMigrationToken

View File

@ -8,6 +8,8 @@ import (
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/cloudmigration"
"github.com/grafana/grafana/pkg/services/cloudmigration/cloudmigrationimpl/fake"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
@ -20,36 +22,55 @@ type TestCase struct {
requestHttpMethod string
requestUrl string
requestBody string
basicRole org.RoleType
user *user.SignedInUser
// if the CloudMigrationService should return an error
serviceReturnError bool
expectedHttpResult int
expectedBody string
}
var (
orgID int64 = 1
userWithPermissions = &user.SignedInUser{
OrgID: orgID,
OrgRole: org.RoleEditor,
Permissions: map[int64]map[string][]string{
orgID: {cloudmigration.ActionMigrate: nil},
},
}
userWithoutPermissions = &user.SignedInUser{
OrgID: orgID,
OrgRole: org.RoleAdmin,
IsGrafanaAdmin: true,
Permissions: map[int64]map[string][]string{},
}
)
func TestCloudMigrationAPI_GetToken(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/token",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"id":"mock_id","displayName":"mock_name","expiresAt":"","firstUsedAt":"","lastUsedAt":"","createdAt":""}`,
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/token",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/token",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
@ -64,26 +85,26 @@ func TestCloudMigrationAPI_GetToken(t *testing.T) {
func TestCloudMigrationAPI_CreateToken(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/token",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"token":"mock_token"}`,
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/token",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/token",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
@ -98,32 +119,32 @@ func TestCloudMigrationAPI_CreateToken(t *testing.T) {
func TestCloudMigrationAPI_DeleteToken(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodDelete,
requestUrl: "/api/cloudmigration/token/1234",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusNoContent,
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodDelete,
requestUrl: "/api/cloudmigration/token/1234",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodDelete,
requestUrl: "/api/cloudmigration/token/1234",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
},
{
desc: "should return 400 if uid is invalid",
desc: "returns 400 if uid is invalid",
requestHttpMethod: http.MethodDelete,
requestUrl: "/api/cloudmigration/token/***",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
@ -137,32 +158,32 @@ func TestCloudMigrationAPI_DeleteToken(t *testing.T) {
func TestCloudMigrationAPI_GetMigration(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusNotFound,
},
{
desc: "should return 400 if uid is invalid",
desc: "returns 400 if uid is invalid",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/****",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
@ -176,26 +197,26 @@ func TestCloudMigrationAPI_GetMigration(t *testing.T) {
func TestCloudMigrationAPI_GetMigrationList(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"sessions":[{"uid":"mock_uid_1","slug":"mock_stack_1","created":"2024-06-05T17:30:40Z","updated":"2024-06-05T17:30:40Z"},{"uid":"mock_uid_2","slug":"mock_stack_2","created":"2024-06-05T17:30:40Z","updated":"2024-06-05T17:30:40Z"}]}`,
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
@ -210,38 +231,38 @@ func TestCloudMigrationAPI_GetMigrationList(t *testing.T) {
func TestCloudMigrationAPI_CreateMigration(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration",
requestBody: `{"auth_token":"asdf"}`,
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"uid":"fake_uid","slug":"fake_stack","created":"2024-06-05T17:30:40Z","updated":"2024-06-05T17:30:40Z"}`,
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration",
requestBody: `{"authToken":"asdf"}`,
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
},
{
desc: "should return 400 if body is not a valid json",
desc: "returns 400 if body is not a valid json",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration",
requestBody: "asdf",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: false,
expectedHttpResult: http.StatusBadRequest,
expectedBody: "",
@ -256,35 +277,35 @@ func TestCloudMigrationAPI_CreateMigration(t *testing.T) {
func TestCloudMigrationAPI_DeleteMigration(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodDelete,
requestUrl: "/api/cloudmigration/migration/1234",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: "",
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodDelete,
requestUrl: "/api/cloudmigration/migration/1234",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodDelete,
requestUrl: "/api/cloudmigration/migration/1234",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
},
{
desc: "should return 400 if uid is invalid",
desc: "returns 400 if uid is invalid",
requestHttpMethod: http.MethodDelete,
requestUrl: "/api/cloudmigration/migration/****",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
@ -298,35 +319,35 @@ func TestCloudMigrationAPI_DeleteMigration(t *testing.T) {
func TestCloudMigrationAPI_CreateSnapshot(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"uid":"fake_uid"}`,
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
},
{
desc: "should return 400 if uid is invalid",
desc: "returns 400 if uid is invalid",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/***/snapshot",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
@ -340,43 +361,43 @@ func TestCloudMigrationAPI_CreateSnapshot(t *testing.T) {
func TestCloudMigrationAPI_GetSnapshot(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"uid":"fake_uid","status":"CREATING","sessionUid":"1234","created":"0001-01-01T00:00:00Z","finished":"0001-01-01T00:00:00Z","results":[{"name":"dashboard name","parentName":"dashboard parent name","type":"DASHBOARD","refId":"123","status":"PENDING"},{"name":"datasource name","parentName":"dashboard parent name","type":"DATASOURCE","refId":"456","status":"OK"}],"stats":{"types":{},"statuses":{},"total":0}}`,
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
},
{
desc: "should return 400 if uid is invalid",
desc: "returns 400 if uid is invalid",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/***/snapshot/1",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
{
desc: "should return 400 if snapshot_uid is invalid",
desc: "returns 400 if snapshot_uid is invalid",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/***",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
@ -390,51 +411,51 @@ func TestCloudMigrationAPI_GetSnapshot(t *testing.T) {
func TestCloudMigrationAPI_GetSnapshotList(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234/snapshots",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"snapshots":[{"uid":"fake_uid","status":"CREATING","sessionUid":"1234","created":"2024-06-05T17:30:40Z","finished":"0001-01-01T00:00:00Z"},{"uid":"fake_uid","status":"CREATING","sessionUid":"1234","created":"2024-06-05T18:30:40Z","finished":"0001-01-01T00:00:00Z"}]}`,
},
{
desc: "with limit query param should return 200 if everything is ok",
desc: "with limit query param returns 200 if everything is ok",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234/snapshots?limit=1",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"snapshots":[{"uid":"fake_uid","status":"CREATING","sessionUid":"1234","created":"2024-06-05T17:30:40Z","finished":"0001-01-01T00:00:00Z"}]}`,
},
{
desc: "with sort query param should return 200 if everything is ok",
desc: "with sort query param returns 200 if everything is ok",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234/snapshots?sort=latest",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: `{"snapshots":[{"uid":"fake_uid","status":"CREATING","sessionUid":"1234","created":"2024-06-05T18:30:40Z","finished":"0001-01-01T00:00:00Z"},{"uid":"fake_uid","status":"CREATING","sessionUid":"1234","created":"2024-06-05T17:30:40Z","finished":"0001-01-01T00:00:00Z"}]}`,
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234/snapshots",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/1234/snapshots",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
},
{
desc: "should return 400 if uid is invalid",
desc: "returns 400 if uid is invalid",
requestHttpMethod: http.MethodGet,
requestUrl: "/api/cloudmigration/migration/***/snapshots",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
@ -448,43 +469,43 @@ func TestCloudMigrationAPI_GetSnapshotList(t *testing.T) {
func TestCloudMigrationAPI_UploadSnapshot(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/upload",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: "",
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/upload",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/upload",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
},
{
desc: "should return 400 if uid is invalid",
desc: "returns 400 if uid is invalid",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/***/snapshot/1/upload",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
{
desc: "should return 400 if snapshot_uid is invalid",
desc: "returns 400 if snapshot_uid is invalid",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/***/upload",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
@ -498,43 +519,43 @@ func TestCloudMigrationAPI_UploadSnapshot(t *testing.T) {
func TestCloudMigrationAPI_CancelSnapshot(t *testing.T) {
tests := []TestCase{
{
desc: "should return 200 if everything is ok",
desc: "returns 200 if the user has the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/cancel",
basicRole: org.RoleAdmin,
user: userWithPermissions,
expectedHttpResult: http.StatusOK,
expectedBody: "",
},
{
desc: "should return 403 if no used is not admin",
desc: "returns 403 if the user does not have the right permissions",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/cancel",
basicRole: org.RoleEditor,
user: userWithoutPermissions,
expectedHttpResult: http.StatusForbidden,
expectedBody: "",
},
{
desc: "should return 500 if service returns an error",
desc: "returns 500 if service returns an error",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1/cancel",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusInternalServerError,
expectedBody: "",
},
{
desc: "should return 400 if uid is invalid",
desc: "returns 400 if uid is invalid",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/***/snapshot/1/cancel",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
{
desc: "should return 400 if snapshot_uid is invalid",
desc: "returns 400 if snapshot_uid is invalid",
requestHttpMethod: http.MethodPost,
requestUrl: "/api/cloudmigration/migration/1234/snapshot/***/cancel",
basicRole: org.RoleAdmin,
user: userWithPermissions,
serviceReturnError: true,
expectedHttpResult: http.StatusBadRequest,
},
@ -548,7 +569,13 @@ func TestCloudMigrationAPI_CancelSnapshot(t *testing.T) {
func runSimpleApiTest(tt TestCase) func(t *testing.T) {
return func(t *testing.T) {
// setup server
api := RegisterApi(routing.NewRouteRegister(), fake.FakeServiceImpl{ReturnError: tt.serviceReturnError}, tracing.InitializeTracerForTest())
api := RegisterApi(
routing.NewRouteRegister(),
fake.FakeServiceImpl{ReturnError: tt.serviceReturnError},
tracing.InitializeTracerForTest(),
acimpl.ProvideAccessControlTest(),
)
server := webtest.NewServer(t, api.routeRegister)
var body io.Reader = nil
@ -559,12 +586,10 @@ func runSimpleApiTest(tt TestCase) func(t *testing.T) {
req.Header.Set("Content-Type", "application/json")
// create test request
webtest.RequestWithSignedInUser(req, &user.SignedInUser{
OrgID: 1,
OrgRole: tt.basicRole,
})
webtest.RequestWithSignedInUser(req, tt.user)
res, err := server.Send(req)
defer func() { require.NoError(t, res.Body.Close()) }()
t.Cleanup(func() { require.NoError(t, res.Body.Close()) })
// validations
require.NoError(t, err)
require.Equal(t, tt.expectedHttpResult, res.StatusCode)

View File

@ -111,6 +111,7 @@ func ProvideService(
pluginStore pluginstore.Store,
pluginSettingsService pluginsettings.Service,
accessControl accesscontrol.AccessControl,
acService accesscontrol.Service,
kvStore kvstore.KVStore,
libraryElementsService libraryelements.Service,
ngAlert *ngalert.AlertNG,
@ -119,6 +120,10 @@ func ProvideService(
return &NoopServiceImpl{}, nil
}
if err := cloudmigration.RegisterAccessControlRoles(acService); err != nil {
return nil, fmt.Errorf("registering access control roles: %w", err)
}
s := &Service{
store: &sqlStore{db: db, secretsStore: secretsStore, secretsService: secretsService},
log: log.New(LogPrefix),
@ -137,7 +142,7 @@ func ProvideService(
libraryElementsService: libraryElementsService,
ngAlert: ngAlert,
}
s.api = api.RegisterApi(routeRegister, s, tracer)
s.api = api.RegisterApi(routeRegister, s, tracer, accessControl)
httpClientS3, err := httpClientProvider.New()
if err != nil {

View File

@ -929,6 +929,7 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool, cfgOverrides ...conf
&pluginstore.FakePluginStore{},
&pluginsettings.FakePluginSettings{},
actest.FakeAccessControl{ExpectedEvaluate: true},
fakeAccessControlService,
kvstore.ProvideService(sqlStore),
&libraryelementsfake.LibraryElementService{},
ng,

View File

@ -1,11 +0,0 @@
package slicesext
func Map[T any, U any](xs []T, f func(T) U) []U {
out := make([]U, 0, len(xs))
for _, x := range xs {
out = append(out, f(x))
}
return out
}

View File

@ -1,36 +0,0 @@
package slicesext_test
import (
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/cloudmigration/slicesext"
)
func TestMap(t *testing.T) {
t.Parallel()
t.Run("mapping a nil slice does nothing and returns an empty slice", func(t *testing.T) {
t.Parallel()
require.Empty(t, slicesext.Map[any, any](nil, nil))
})
t.Run("mapping a non-nil slice with a nil function panics", func(t *testing.T) {
t.Parallel()
require.Panics(t, func() { slicesext.Map[int, any]([]int{1, 2, 3}, nil) })
})
t.Run("mapping a non-nil slice with a non-nil function returns the mapped slice", func(t *testing.T) {
t.Parallel()
original := []int{1, 2, 3}
expected := []string{"1", "2", "3"}
fn := func(i int) string { return strconv.Itoa(i) }
require.ElementsMatch(t, expected, slicesext.Map(original, fn))
})
}

View File

@ -4,11 +4,11 @@ import (
"github.com/grafana/grafana/pkg/login/social"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/ssoutils"
"github.com/grafana/grafana/pkg/services/cloudmigration"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/correlations"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/navtree"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/setting"
@ -61,7 +61,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
Url: s.cfg.AppSubURL + "/admin/storage",
})
}
if s.features.IsEnabled(ctx, featuremgmt.FlagOnPremToCloudMigrations) && c.SignedInUser.HasRole(org.RoleAdmin) {
if hasAccess(cloudmigration.MigrationAssistantAccess) && s.features.IsEnabled(ctx, featuremgmt.FlagOnPremToCloudMigrations) {
generalNodeLinks = append(generalNodeLinks, &navtree.NavLink{
Text: "Migrate to Grafana Cloud",
Id: "migrate-to-cloud",

View File

@ -377,7 +377,7 @@ export function getAppRoutes(): RouteDescriptor[] {
},
config.featureToggles.onPremToCloudMigrations && {
path: '/admin/migrate-to-cloud',
roles: () => ['Admin'],
roles: () => contextSrv.evaluatePermission([AccessControlAction.MigrationAssistantMigrate]),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "MigrateToCloud" */ 'app/features/migrate-to-cloud/MigrateToCloud')
),

View File

@ -164,6 +164,9 @@ export enum AccessControlAction {
// GroupSync
GroupSyncMappingsRead = 'groupsync.mappings:read',
GroupSyncMappingsWrite = 'groupsync.mappings:write',
// Migration Assistant
MigrationAssistantMigrate = 'migrationassistant:migrate',
}
export interface Role {