mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
RBAC: Add access control metadata to folder dtos (#51158)
* RBAC: Add access control metadata to Folder dto * Add access control metadata to folder dto response * Add test to verify that access control metadata is attached * Attach access control metadata to multiple folders * Add access control metadata to frontend folder dto
This commit is contained in:
parent
8fa951df04
commit
2698e37291
@ -1,26 +1,32 @@
|
|||||||
package dtos
|
package dtos
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
)
|
||||||
|
|
||||||
type Folder struct {
|
type Folder struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Uid string `json:"uid"`
|
Uid string `json:"uid"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
HasAcl bool `json:"hasAcl"`
|
HasAcl bool `json:"hasAcl"`
|
||||||
CanSave bool `json:"canSave"`
|
CanSave bool `json:"canSave"`
|
||||||
CanEdit bool `json:"canEdit"`
|
CanEdit bool `json:"canEdit"`
|
||||||
CanAdmin bool `json:"canAdmin"`
|
CanAdmin bool `json:"canAdmin"`
|
||||||
CanDelete bool `json:"canDelete"`
|
CanDelete bool `json:"canDelete"`
|
||||||
CreatedBy string `json:"createdBy"`
|
CreatedBy string `json:"createdBy"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
UpdatedBy string `json:"updatedBy"`
|
UpdatedBy string `json:"updatedBy"`
|
||||||
Updated time.Time `json:"updated"`
|
Updated time.Time `json:"updated"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
|
AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FolderSearchHit struct {
|
type FolderSearchHit struct {
|
||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Uid string `json:"uid"`
|
Uid string `json:"uid"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"`
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -11,6 +10,7 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/guardian"
|
"github.com/grafana/grafana/pkg/services/guardian"
|
||||||
"github.com/grafana/grafana/pkg/services/libraryelements"
|
"github.com/grafana/grafana/pkg/services/libraryelements"
|
||||||
"github.com/grafana/grafana/pkg/services/store"
|
"github.com/grafana/grafana/pkg/services/store"
|
||||||
@ -25,9 +25,10 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response {
|
|||||||
return apierrors.ToFolderErrorResponse(err)
|
return apierrors.ToFolderErrorResponse(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
uids := make(map[string]bool, len(folders))
|
||||||
result := make([]dtos.FolderSearchHit, 0)
|
result := make([]dtos.FolderSearchHit, 0)
|
||||||
|
|
||||||
for _, f := range folders {
|
for _, f := range folders {
|
||||||
|
uids[f.Uid] = true
|
||||||
result = append(result, dtos.FolderSearchHit{
|
result = append(result, dtos.FolderSearchHit{
|
||||||
Id: f.Id,
|
Id: f.Id,
|
||||||
Uid: f.Uid,
|
Uid: f.Uid,
|
||||||
@ -35,6 +36,13 @@ func (hs *HTTPServer) GetFolders(c *models.ReqContext) response.Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metadata := hs.getMultiAccessControlMetadata(c, c.OrgId, dashboards.ScopeFoldersPrefix, uids)
|
||||||
|
if len(metadata) > 0 {
|
||||||
|
for i := range result {
|
||||||
|
result[i].AccessControl = metadata[result[i].Uid]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return response.JSON(http.StatusOK, result)
|
return response.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +53,7 @@ func (hs *HTTPServer) GetFolderByUID(c *models.ReqContext) response.Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser)
|
g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser)
|
||||||
return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder))
|
return response.JSON(http.StatusOK, hs.toFolderDto(c, g, folder))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response {
|
func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response {
|
||||||
@ -59,7 +67,7 @@ func (hs *HTTPServer) GetFolderByID(c *models.ReqContext) response.Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser)
|
g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser)
|
||||||
return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder))
|
return response.JSON(http.StatusOK, hs.toFolderDto(c, g, folder))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response {
|
func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response {
|
||||||
@ -81,7 +89,7 @@ func (hs *HTTPServer) CreateFolder(c *models.ReqContext) response.Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser)
|
g := guardian.New(c.Req.Context(), folder.Id, c.OrgId, c.SignedInUser)
|
||||||
return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, folder))
|
return response.JSON(http.StatusOK, hs.toFolderDto(c, g, folder))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response {
|
func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response {
|
||||||
@ -103,7 +111,7 @@ func (hs *HTTPServer) UpdateFolder(c *models.ReqContext) response.Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
g := guardian.New(c.Req.Context(), cmd.Result.Id, c.OrgId, c.SignedInUser)
|
g := guardian.New(c.Req.Context(), cmd.Result.Id, c.OrgId, c.SignedInUser)
|
||||||
return response.JSON(http.StatusOK, hs.toFolderDto(c.Req.Context(), g, cmd.Result))
|
return response.JSON(http.StatusOK, hs.toFolderDto(c, g, cmd.Result))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed
|
func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed
|
||||||
@ -136,7 +144,7 @@ func (hs *HTTPServer) DeleteFolder(c *models.ReqContext) response.Response { //
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (hs *HTTPServer) toFolderDto(ctx context.Context, g guardian.DashboardGuardian, folder *models.Folder) dtos.Folder {
|
func (hs *HTTPServer) toFolderDto(c *models.ReqContext, g guardian.DashboardGuardian, folder *models.Folder) dtos.Folder {
|
||||||
canEdit, _ := g.CanEdit()
|
canEdit, _ := g.CanEdit()
|
||||||
canSave, _ := g.CanSave()
|
canSave, _ := g.CanSave()
|
||||||
canAdmin, _ := g.CanAdmin()
|
canAdmin, _ := g.CanAdmin()
|
||||||
@ -145,26 +153,27 @@ func (hs *HTTPServer) toFolderDto(ctx context.Context, g guardian.DashboardGuard
|
|||||||
// Finding creator and last updater of the folder
|
// Finding creator and last updater of the folder
|
||||||
updater, creator := anonString, anonString
|
updater, creator := anonString, anonString
|
||||||
if folder.CreatedBy > 0 {
|
if folder.CreatedBy > 0 {
|
||||||
creator = hs.getUserLogin(ctx, folder.CreatedBy)
|
creator = hs.getUserLogin(c.Req.Context(), folder.CreatedBy)
|
||||||
}
|
}
|
||||||
if folder.UpdatedBy > 0 {
|
if folder.UpdatedBy > 0 {
|
||||||
updater = hs.getUserLogin(ctx, folder.UpdatedBy)
|
updater = hs.getUserLogin(c.Req.Context(), folder.UpdatedBy)
|
||||||
}
|
}
|
||||||
|
|
||||||
return dtos.Folder{
|
return dtos.Folder{
|
||||||
Id: folder.Id,
|
Id: folder.Id,
|
||||||
Uid: folder.Uid,
|
Uid: folder.Uid,
|
||||||
Title: folder.Title,
|
Title: folder.Title,
|
||||||
Url: folder.Url,
|
Url: folder.Url,
|
||||||
HasAcl: folder.HasAcl,
|
HasAcl: folder.HasAcl,
|
||||||
CanSave: canSave,
|
CanSave: canSave,
|
||||||
CanEdit: canEdit,
|
CanEdit: canEdit,
|
||||||
CanAdmin: canAdmin,
|
CanAdmin: canAdmin,
|
||||||
CanDelete: canDelete,
|
CanDelete: canDelete,
|
||||||
CreatedBy: creator,
|
CreatedBy: creator,
|
||||||
Created: folder.Created,
|
Created: folder.Created,
|
||||||
UpdatedBy: updater,
|
UpdatedBy: updater,
|
||||||
Updated: folder.Updated,
|
Updated: folder.Updated,
|
||||||
Version: folder.Version,
|
Version: folder.Version,
|
||||||
|
AccessControl: hs.getAccessControlMetadata(c, c.OrgId, dashboards.ScopeFoldersPrefix, folder.Uid),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,15 +4,19 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/api/dtos"
|
"github.com/grafana/grafana/pkg/api/dtos"
|
||||||
"github.com/grafana/grafana/pkg/api/response"
|
"github.com/grafana/grafana/pkg/api/response"
|
||||||
"github.com/grafana/grafana/pkg/api/routing"
|
"github.com/grafana/grafana/pkg/api/routing"
|
||||||
"github.com/grafana/grafana/pkg/models"
|
"github.com/grafana/grafana/pkg/models"
|
||||||
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
|
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
"github.com/grafana/grafana/pkg/web/webtest"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@ -130,16 +134,106 @@ func TestFoldersAPIEndpoint(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHTTPServer_FolderMetadata(t *testing.T) {
|
||||||
|
setUpRBACGuardian(t)
|
||||||
|
folderService := dashboards.NewFakeFolderService(t)
|
||||||
|
server := SetupAPITestServer(t, func(hs *HTTPServer) {
|
||||||
|
hs.folderService = folderService
|
||||||
|
hs.AccessControl = acmock.New()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should attach access control metadata to multiple folders", func(t *testing.T) {
|
||||||
|
folderService.On("GetFolders", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*models.Folder{
|
||||||
|
{Uid: "1"},
|
||||||
|
{Uid: "2"},
|
||||||
|
{Uid: "3"},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
req := server.NewGetRequest("/api/folders?accesscontrol=true")
|
||||||
|
webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, Permissions: map[int64]map[string][]string{
|
||||||
|
1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{
|
||||||
|
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
|
||||||
|
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("2")},
|
||||||
|
}),
|
||||||
|
}})
|
||||||
|
|
||||||
|
res, err := server.Send(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||||
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
|
|
||||||
|
body := []dtos.FolderSearchHit{}
|
||||||
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
|
||||||
|
|
||||||
|
for _, f := range body {
|
||||||
|
assert.True(t, f.AccessControl[dashboards.ActionFoldersRead])
|
||||||
|
if f.Uid == "2" {
|
||||||
|
assert.True(t, f.AccessControl[dashboards.ActionFoldersWrite])
|
||||||
|
} else {
|
||||||
|
assert.False(t, f.AccessControl[dashboards.ActionFoldersWrite])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should attach access control metadata to folder response", func(t *testing.T) {
|
||||||
|
folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&models.Folder{Uid: "folderUid"}, nil)
|
||||||
|
|
||||||
|
req := server.NewGetRequest("/api/folders/folderUid?accesscontrol=true")
|
||||||
|
webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, Permissions: map[int64]map[string][]string{
|
||||||
|
1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{
|
||||||
|
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
|
||||||
|
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("folderUid")},
|
||||||
|
}),
|
||||||
|
}})
|
||||||
|
|
||||||
|
res, err := server.Send(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||||
|
|
||||||
|
body := dtos.Folder{}
|
||||||
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
|
||||||
|
|
||||||
|
assert.True(t, body.AccessControl[dashboards.ActionFoldersRead])
|
||||||
|
assert.True(t, body.AccessControl[dashboards.ActionFoldersWrite])
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Should attach access control metadata to folder response", func(t *testing.T) {
|
||||||
|
folderService.On("GetFolderByUID", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&models.Folder{Uid: "folderUid"}, nil)
|
||||||
|
|
||||||
|
req := server.NewGetRequest("/api/folders/folderUid")
|
||||||
|
webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, Permissions: map[int64]map[string][]string{
|
||||||
|
1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{
|
||||||
|
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
|
||||||
|
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("folderUid")},
|
||||||
|
}),
|
||||||
|
}})
|
||||||
|
|
||||||
|
res, err := server.Send(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, res.StatusCode)
|
||||||
|
defer func() { require.NoError(t, res.Body.Close()) }()
|
||||||
|
|
||||||
|
body := dtos.Folder{}
|
||||||
|
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
|
||||||
|
|
||||||
|
assert.False(t, body.AccessControl[dashboards.ActionFoldersRead])
|
||||||
|
assert.False(t, body.AccessControl[dashboards.ActionFoldersWrite])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func callCreateFolder(sc *scenarioContext) {
|
func callCreateFolder(sc *scenarioContext) {
|
||||||
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
sc.fakeReqWithParams("POST", sc.url, map[string]string{}).exec()
|
||||||
}
|
}
|
||||||
|
|
||||||
func createFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService,
|
func createFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService,
|
||||||
cmd models.CreateFolderCommand, fn scenarioFunc) {
|
cmd models.CreateFolderCommand, fn scenarioFunc) {
|
||||||
|
setUpRBACGuardian(t)
|
||||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||||
hs := HTTPServer{
|
hs := HTTPServer{
|
||||||
Cfg: setting.NewCfg(),
|
AccessControl: acmock.New(),
|
||||||
folderService: folderService,
|
folderService: folderService,
|
||||||
|
Cfg: setting.NewCfg(),
|
||||||
Features: featuremgmt.WithFeatures(),
|
Features: featuremgmt.WithFeatures(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,9 +259,11 @@ func callUpdateFolder(sc *scenarioContext) {
|
|||||||
|
|
||||||
func updateFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService,
|
func updateFolderScenario(t *testing.T, desc string, url string, routePattern string, folderService dashboards.FolderService,
|
||||||
cmd models.UpdateFolderCommand, fn scenarioFunc) {
|
cmd models.UpdateFolderCommand, fn scenarioFunc) {
|
||||||
|
setUpRBACGuardian(t)
|
||||||
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) {
|
||||||
hs := HTTPServer{
|
hs := HTTPServer{
|
||||||
Cfg: setting.NewCfg(),
|
Cfg: setting.NewCfg(),
|
||||||
|
AccessControl: acmock.New(),
|
||||||
folderService: folderService,
|
folderService: folderService,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -88,10 +88,18 @@ func (m *Mock) Evaluate(ctx context.Context, user *models.SignedInUser, evaluato
|
|||||||
if m.EvaluateFunc != nil {
|
if m.EvaluateFunc != nil {
|
||||||
return m.EvaluateFunc(ctx, user, evaluator)
|
return m.EvaluateFunc(ctx, user, evaluator)
|
||||||
}
|
}
|
||||||
// Otherwise perform an actual evaluation of the permissions
|
|
||||||
permissions, err := m.GetUserPermissions(ctx, user, accesscontrol.Options{ReloadCache: false})
|
var permissions map[string][]string
|
||||||
if err != nil {
|
if user.Permissions != nil && user.Permissions[user.OrgId] != nil {
|
||||||
return false, err
|
permissions = user.Permissions[user.OrgId]
|
||||||
|
}
|
||||||
|
|
||||||
|
if permissions == nil {
|
||||||
|
userPermissions, err := m.GetUserPermissions(ctx, user, accesscontrol.Options{ReloadCache: true})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
permissions = accesscontrol.GroupScopesByAction(userPermissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeMutator := m.scopeResolvers.GetScopeAttributeMutator(user.OrgId)
|
attributeMutator := m.scopeResolvers.GetScopeAttributeMutator(user.OrgId)
|
||||||
@ -99,7 +107,7 @@ func (m *Mock) Evaluate(ctx context.Context, user *models.SignedInUser, evaluato
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return resolvedEvaluator.Evaluate(accesscontrol.GroupScopesByAction(permissions)), nil
|
return resolvedEvaluator.Evaluate(permissions), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserPermissions returns user permissions.
|
// GetUserPermissions returns user permissions.
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
|
import { WithAccessControlMetadata } from '@grafana/data';
|
||||||
|
|
||||||
import { DashboardAcl } from './acl';
|
import { DashboardAcl } from './acl';
|
||||||
|
|
||||||
export interface FolderDTO {
|
export interface FolderDTO extends WithAccessControlMetadata {
|
||||||
id: number;
|
id: number;
|
||||||
uid: string;
|
uid: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user