Library Panels: Modify connection api endpoint to be compatible with unified storage (#107088)
This commit is contained in:
@@ -2,17 +2,21 @@ package libraryelements
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net/http"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/api/routing"
|
||||
"github.com/grafana/grafana/pkg/infra/metrics"
|
||||
"github.com/grafana/grafana/pkg/kinds/librarypanel"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/folder"
|
||||
"github.com/grafana/grafana/pkg/services/libraryelements/model"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
@@ -274,9 +278,66 @@ func (l *LibraryElementService) patchHandler(c *contextmodel.ReqContext) respons
|
||||
// 404: notFoundError
|
||||
// 500: internalServerError
|
||||
func (l *LibraryElementService) getConnectionsHandler(c *contextmodel.ReqContext) response.Response {
|
||||
connections, err := l.getConnections(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
|
||||
libraryPanelUID := web.Params(c.Req)[":uid"]
|
||||
|
||||
// make sure the library element exists
|
||||
element, err := l.getLibraryElementByUid(c.Req.Context(), c.SignedInUser, model.GetLibraryElementCommand{
|
||||
UID: libraryPanelUID,
|
||||
})
|
||||
if err != nil {
|
||||
return l.toLibraryElementError(err, "Failed to get connections")
|
||||
return l.toLibraryElementError(err, "Failed to get library element")
|
||||
}
|
||||
|
||||
// now get all dashboards connected to this library element
|
||||
dashboards, err := l.dashboardsService.GetDashboardsByLibraryPanelUID(c.Req.Context(), libraryPanelUID, c.GetOrgID())
|
||||
if err != nil {
|
||||
return l.toLibraryElementError(err, "Failed to get dashboards")
|
||||
}
|
||||
|
||||
ids, err := l.getConnectionIDs(c.Req.Context(), c.SignedInUser, libraryPanelUID)
|
||||
if err != nil {
|
||||
return l.toLibraryElementError(err, "Failed to get connection ids")
|
||||
}
|
||||
|
||||
connections := make([]model.LibraryElementConnectionDTO, 0)
|
||||
for _, dashboard := range dashboards {
|
||||
// skip checks if the user is an admin, or if the dashboard is in the general folder
|
||||
if !c.HasRole(org.RoleAdmin) && dashboard.FolderUID != "" && dashboard.FolderUID != "general" {
|
||||
if err := l.requireViewPermissionsOnFolderUID(c.Req.Context(), c.SignedInUser, dashboard.FolderUID); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// best effort to get a connection id, once in unified storage, connections are not an individual resource and therefore do not have an id
|
||||
connectionID, ok := ids[getConnectionKey(element.ID, dashboard.ID)] // nolint:staticcheck
|
||||
if !ok {
|
||||
// if we cannot get an ID from the db, instead do a best effort to return something that will be consistent and somewhat unique for the connection.
|
||||
// note: the connection ID cannot be used to get, update, or delete a connection, so this is solely to keep the api returning the same fields for now,
|
||||
// while we deprecate the endpoint.
|
||||
hash := fnv.New64a()
|
||||
_, err := fmt.Fprintf(hash, "%d:%s:%d:%d", element.ID, dashboard.UID, c.GetOrgID(), element.Meta.Created.Unix())
|
||||
if err != nil {
|
||||
return l.toLibraryElementError(err, "Failed to generate connection id")
|
||||
}
|
||||
// ensure it is positive and smaller than 9007199254740991, otherwise we will lose prescision
|
||||
// in javascript, which has the safest number as 9007199254740991, compared to 9223372036854775807 in go
|
||||
connectionID = int64(hash.Sum64() & ((1 << 52) - 1))
|
||||
}
|
||||
|
||||
connections = append(connections, model.LibraryElementConnectionDTO{
|
||||
ID: connectionID,
|
||||
Kind: int64(model.PanelElement),
|
||||
ElementID: element.ID,
|
||||
ConnectionID: dashboard.ID, // nolint:staticcheck
|
||||
ConnectionUID: dashboard.UID,
|
||||
// returns the creation information of the library element, not the connection
|
||||
CreatedBy: librarypanel.LibraryElementDTOMetaUser{
|
||||
Id: element.Meta.CreatedBy.Id,
|
||||
Name: element.Meta.CreatedBy.Name,
|
||||
AvatarUrl: element.Meta.CreatedBy.AvatarUrl,
|
||||
},
|
||||
Created: element.Meta.Created,
|
||||
})
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, model.LibraryElementConnectionsResponse{Result: connections})
|
||||
|
||||
@@ -712,66 +712,27 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU
|
||||
return dto, err
|
||||
}
|
||||
|
||||
// getConnections gets all connections for a Library Element.
|
||||
func (l *LibraryElementService) getConnections(c context.Context, signedInUser identity.Requester, uid string) ([]model.LibraryElementConnectionDTO, error) {
|
||||
connections := make([]model.LibraryElementConnectionDTO, 0)
|
||||
// getConnectionIDs returns a map[string]int64 with the key as elementID:connectionUID and the value as connectionID
|
||||
func (l *LibraryElementService) getConnectionIDs(c context.Context, signedInUser identity.Requester, uid string) (map[string]int64, error) {
|
||||
connections := map[string]int64{}
|
||||
recursiveQueriesAreSupported, err := l.SQLStore.RecursiveQueriesAreSupported()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = l.SQLStore.WithDbSession(c, func(session *db.Session) error {
|
||||
element, err := l.GetLibraryElement(c, signedInUser, session, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var libraryElementConnections []model.LibraryElementConnectionWithMeta
|
||||
builder := db.NewSqlBuilder(l.Cfg, l.features, l.SQLStore.GetDialect(), recursiveQueriesAreSupported)
|
||||
builder.Write("SELECT lec.*, u1.login AS created_by_name, u1.email AS created_by_email")
|
||||
builder.Write(" FROM " + model.LibraryElementConnectionTableName + " AS lec")
|
||||
builder.Write(" LEFT JOIN " + l.SQLStore.GetDialect().Quote("user") + " AS u1 ON lec.created_by = u1.id")
|
||||
builder.Write(` WHERE lec.element_id=?`, element.ID)
|
||||
builder.Write("SELECT lec.id, lec.element_id, lec.connection_id")
|
||||
builder.Write(" FROM " + model.LibraryElementConnectionTableName + " AS lec ")
|
||||
builder.Write(" INNER JOIN " + model.LibraryElementTableName + " AS le ON le.id = element_id")
|
||||
builder.Write(" WHERE le.org_id=? AND le.uid=?", signedInUser.GetOrgID(), uid)
|
||||
if err := session.SQL(builder.GetSQLString(), builder.GetParams()...).Find(&libraryElementConnections); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// getting all folders a user can see
|
||||
fs, err := l.folderService.GetFolders(c, folder.GetFoldersQuery{OrgID: signedInUser.GetOrgID(), SignedInUser: signedInUser})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Every signed in user can see the general folder. The general folder might have "general" or the empty string as its UID.
|
||||
var folderUIDS = []string{"general", ""}
|
||||
for _, f := range fs {
|
||||
folderUIDS = append(folderUIDS, f.UID)
|
||||
}
|
||||
// if the user is not an admin, we need to filter out elements that are not in folders the user can see
|
||||
for _, connection := range libraryElementConnections {
|
||||
if !signedInUser.HasRole(org.RoleAdmin) {
|
||||
if !contains(folderUIDS, element.FolderUID) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
ds, err := l.dashboardsService.GetDashboardUIDByID(c, &dashboards.GetDashboardRefByIDQuery{ID: connection.ConnectionID})
|
||||
if err != nil {
|
||||
if errors.Is(err, dashboards.ErrDashboardNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
connections = append(connections, model.LibraryElementConnectionDTO{
|
||||
ID: connection.ID,
|
||||
Kind: connection.Kind,
|
||||
ElementID: connection.ElementID,
|
||||
ConnectionID: connection.ConnectionID,
|
||||
ConnectionUID: ds.UID,
|
||||
Created: connection.Created,
|
||||
CreatedBy: librarypanel.LibraryElementDTOMetaUser{
|
||||
Id: connection.CreatedBy,
|
||||
Name: connection.CreatedByName,
|
||||
AvatarUrl: dtos.GetGravatarUrl(l.Cfg, connection.CreatedByEmail),
|
||||
},
|
||||
})
|
||||
connections[getConnectionKey(connection.ElementID, connection.ConnectionID)] = connection.ID
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -780,6 +741,10 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser i
|
||||
return connections, err
|
||||
}
|
||||
|
||||
func getConnectionKey(elementID int64, connectionID int64) string {
|
||||
return fmt.Sprintf("%d:%d", elementID, connectionID)
|
||||
}
|
||||
|
||||
// getElementsForDashboardID gets all elements for a specific dashboard
|
||||
func (l *LibraryElementService) getElementsForDashboardID(c context.Context, dashboardID int64) (map[string]model.LibraryElementDTO, error) {
|
||||
libraryElementMap := make(map[string]model.LibraryElementDTO)
|
||||
|
||||
@@ -91,3 +91,16 @@ func (l *LibraryElementService) requireViewPermissionsOnFolder(ctx context.Conte
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LibraryElementService) requireViewPermissionsOnFolderUID(ctx context.Context, user identity.Requester, folderUID string) error {
|
||||
evaluator := accesscontrol.EvalPermission(dashboards.ActionFoldersRead, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID))
|
||||
canView, err := l.AccessControl.Evaluate(ctx, user, evaluator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canView {
|
||||
return dashboards.ErrFolderAccessDenied
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -209,6 +209,80 @@ func TestIntegration_GetLibraryPanelConnections(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
scenarioWithPanel(t, "When a user tries to get connections of library panel, dashboards in inaccessible folders should not be returned",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
accessibleFolder := createFolder(t, sc, "AccessibleFolder", sc.service.folderService)
|
||||
inaccessibleFolder := createFolder(t, sc, "InAccessibleFolder", sc.service.folderService)
|
||||
restrictedUser := user.SignedInUser{
|
||||
UserID: 2,
|
||||
Name: "Non-Admin User",
|
||||
Login: "non-admin-user",
|
||||
OrgID: sc.user.OrgID,
|
||||
OrgRole: org.RoleViewer,
|
||||
LastSeenAt: time.Now(),
|
||||
Permissions: map[int64]map[string][]string{
|
||||
sc.user.OrgID: {
|
||||
dashboards.ActionFoldersRead: {
|
||||
dashboards.ScopeFoldersProvider.GetResourceScopeUID(accessibleFolder.UID),
|
||||
},
|
||||
dashboards.ActionDashboardsRead: {dashboards.ScopeDashboardsProvider.GetResourceScopeUID("*")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
command := getCreatePanelCommand(accessibleFolder.ID, accessibleFolder.UID, "Accessible Library Panel") // nolint:staticcheck
|
||||
sc.reqContext.Req.Body = mockRequestBody(command)
|
||||
resp := sc.service.createHandler(sc.reqContext)
|
||||
libraryElement := validateAndUnMarshalResponse(t, resp)
|
||||
|
||||
dashJSON := map[string]any{
|
||||
"panels": []any{
|
||||
map[string]any{
|
||||
"id": int64(1),
|
||||
"gridPos": map[string]any{
|
||||
"h": 6,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
},
|
||||
"libraryPanel": map[string]any{
|
||||
"uid": libraryElement.Result.UID,
|
||||
"name": libraryElement.Result.Name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
accessibleDash := dashboards.Dashboard{
|
||||
Title: "Accessible Dashboard",
|
||||
Data: simplejson.NewFromAny(dashJSON),
|
||||
}
|
||||
|
||||
// create the dashboard in the general folder, an accessible folder, and an inaccessible folder
|
||||
dashInGeneral := createDashboard(t, sc.sqlStore, restrictedUser, &accessibleDash, 0, "")
|
||||
err := sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{libraryElement.Result.UID}, dashInGeneral.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
dashInAccessibleFolder := createDashboard(t, sc.sqlStore, restrictedUser, &accessibleDash, 0, accessibleFolder.UID)
|
||||
err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{libraryElement.Result.UID}, dashInAccessibleFolder.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
dashInInaccessibleFolder := createDashboard(t, sc.sqlStore, restrictedUser, &accessibleDash, 0, inaccessibleFolder.UID)
|
||||
err = sc.service.ConnectElementsToDashboard(sc.reqContext.Req.Context(), sc.reqContext.SignedInUser, []string{libraryElement.Result.UID}, dashInInaccessibleFolder.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
sc.reqContext.SignedInUser = &restrictedUser
|
||||
sc.ctx.Req = web.SetURLParams(sc.ctx.Req, map[string]string{":uid": libraryElement.Result.UID})
|
||||
|
||||
// connections should return the general folder one and the accessible folder one
|
||||
connectionsResp := sc.service.getConnectionsHandler(sc.reqContext)
|
||||
var result = validateAndUnMarshalConnectionResponse(t, connectionsResp)
|
||||
require.Len(t, result.Result, 2)
|
||||
uids := []string{result.Result[0].ConnectionUID, result.Result[1].ConnectionUID}
|
||||
require.Contains(t, uids, dashInGeneral.UID)
|
||||
require.Contains(t, uids, dashInAccessibleFolder.UID)
|
||||
require.NotContains(t, uids, dashInInaccessibleFolder.UID)
|
||||
})
|
||||
|
||||
scenarioWithPanel(t, "When an admin tries to create a connection with an element that exists, but the original folder does not, it should still succeed",
|
||||
func(t *testing.T, sc scenarioContext) {
|
||||
b, err := json.Marshal(map[string]string{"test": "test"})
|
||||
|
||||
@@ -128,6 +128,7 @@ type LibraryElementConnectionWithMeta struct {
|
||||
|
||||
// LibraryElementConnectionDTO is the frontend DTO for element connections.
|
||||
type LibraryElementConnectionDTO struct {
|
||||
// Deprecated: this field will be removed in the future
|
||||
ID int64 `json:"id"`
|
||||
Kind int64 `json:"kind"`
|
||||
ElementID int64 `json:"elementId"`
|
||||
@@ -263,4 +264,5 @@ const (
|
||||
PanelElement LibraryElementKind = iota + 1
|
||||
)
|
||||
|
||||
const LibraryElementTableName = "library_element"
|
||||
const LibraryElementConnectionTableName = "library_element_connection"
|
||||
|
||||
Reference in New Issue
Block a user