Merge remote-tracking branch 'origin/main' into drclau/unistor/replace-authenticators-3

This commit is contained in:
gamab
2024-10-09 12:13:25 +02:00
403 changed files with 11554 additions and 5284 deletions
+23 -231
View File
@@ -3863,13 +3863,9 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
], ],
"public/app/features/explore/TraceView/TraceView.tsx:5381": [ "public/app/features/explore/TraceView/TraceView.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "2"]
[0, 0, 0, "Do not use any type assertions.", "3"]
],
"public/app/features/explore/TraceView/components/TracePageHeader/Actions/ActionButton.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
], ],
"public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx:5381": [ "public/app/features/explore/TraceView/components/TracePageHeader/Actions/TracePageActions.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
@@ -3882,69 +3878,24 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"]
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"],
[0, 0, 0, "Styles should be written using objects.", "12"],
[0, 0, 0, "Styles should be written using objects.", "13"]
], ],
"public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx:5381": [ "public/app/features/explore/TraceView/components/TracePageHeader/SearchBar/TracePageSearchBar.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"]
], ],
"public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx:5381": [ "public/app/features/explore/TraceView/components/TracePageHeader/SpanFilters/SpanFilters.tsx:5381": [
[0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"], [0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"]
],
"public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/CanvasSpanGraph.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/GraphTicks.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/Scrubber.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"]
],
"public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/TickLabels.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
], ],
"public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx:5381": [ "public/app/features/explore/TraceView/components/TracePageHeader/SpanGraph/ViewingLayer.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"]
], ],
"public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx:5381": [ "public/app/features/explore/TraceView/components/TracePageHeader/TracePageHeader.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"]
], ],
"public/app/features/explore/TraceView/components/TracePageHeader/index.tsx:5381": [ "public/app/features/explore/TraceView/components/TracePageHeader/index.tsx:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./TracePageHeader\`)", "0"] [0, 0, 0, "Do not re-export imported variable (\`./TracePageHeader\`)", "0"]
@@ -3954,193 +3905,42 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
], ],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"],
[0, 0, 0, "Styles should be written using objects.", "12"],
[0, 0, 0, "Styles should be written using objects.", "13"],
[0, 0, 0, "Styles should be written using objects.", "14"],
[0, 0, 0, "Styles should be written using objects.", "15"],
[0, 0, 0, "Styles should be written using objects.", "16"],
[0, 0, 0, "Styles should be written using objects.", "17"],
[0, 0, 0, "Styles should be written using objects.", "18"],
[0, 0, 0, "Styles should be written using objects.", "19"],
[0, 0, 0, "Styles should be written using objects.", "20"],
[0, 0, 0, "Styles should be written using objects.", "21"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx:5381": [ "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"]
], ],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx:5381": [ "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"]
], ],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx:5381": [ "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "Styles should be written using objects.", "11"],
[0, 0, 0, "Styles should be written using objects.", "12"],
[0, 0, 0, "Styles should be written using objects.", "13"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "14"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "15"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "16"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "17"]
], ],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx:5381": [ "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianText.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"]
], ],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx:5381": [ "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/SpanFlameGraph.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
], ],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/TextList.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx:5381": [ "public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/index.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"],
[0, 0, 0, "Styles should be written using objects.", "9"],
[0, 0, 0, "Styles should be written using objects.", "10"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "11"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetailRow.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanLinks.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanTreeOffset.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/Ticks.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineCollapser.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineColumnResizer.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"]
], ],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx:5381": [ "public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineHeaderRow.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/TimelineViewingLayer.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"]
], ],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/index.tsx:5381": [ "public/app/features/explore/TraceView/components/TraceTimelineViewer/TimelineHeaderRow/index.tsx:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./TimelineHeaderRow\`)", "0"] [0, 0, 0, "Do not re-export imported variable (\`./TimelineHeaderRow\`)", "0"]
], ],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/VirtualizedTraceView.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/index.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/utils.tsx:5381": [ "public/app/features/explore/TraceView/components/TraceTimelineViewer/utils.tsx:5381": [
[0, 0, 0, "Do not re-export imported variable (\`../utils/date\`)", "0"] [0, 0, 0, "Do not re-export imported variable (\`../utils/date\`)", "0"]
], ],
"public/app/features/explore/TraceView/components/common/BreakableText.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/explore/TraceView/components/common/CopyIcon.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/explore/TraceView/components/common/LabeledList.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"]
],
"public/app/features/explore/TraceView/components/common/NewWindowIcon.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/explore/TraceView/components/common/TraceName.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/explore/TraceView/components/demo/trace-generators.ts:5381": [ "public/app/features/explore/TraceView/components/demo/trace-generators.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
@@ -4175,10 +3975,6 @@ exports[`better eslint`] = {
"public/app/features/explore/TraceView/components/model/transform-trace-data.tsx:5381": [ "public/app/features/explore/TraceView/components/model/transform-trace-data.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"public/app/features/explore/TraceView/components/settings/SpanBarSettings.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/explore/TraceView/components/types/index.tsx:5381": [ "public/app/features/explore/TraceView/components/types/index.tsx:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./trace\`)", "0"], [0, 0, 0, "Do not re-export imported variable (\`./trace\`)", "0"],
[0, 0, 0, "Do not re-export imported variable (\`../settings/SpanBarSettings\`)", "1"], [0, 0, 0, "Do not re-export imported variable (\`../settings/SpanBarSettings\`)", "1"],
@@ -4593,8 +4389,7 @@ exports[`better eslint`] = {
"public/app/features/migrate-to-cloud/onprem/NameCell.tsx:5381": [ "public/app/features/migrate-to-cloud/onprem/NameCell.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "1"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"], [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
], ],
"public/app/features/notifications/StoredNotifications.tsx:5381": [ "public/app/features/notifications/StoredNotifications.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"] [0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
@@ -5225,9 +5020,6 @@ exports[`better eslint`] = {
"public/app/features/teams/state/reducers.ts:5381": [ "public/app/features/teams/state/reducers.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]
], ],
"public/app/features/teams/state/selectors.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/templating/fieldAccessorCache.ts:5381": [ "public/app/features/templating/fieldAccessorCache.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],
+2 -140
View File
@@ -4332,142 +4332,6 @@ volumes:
clone: clone:
retries: 3 retries: 3
depends_on: [] depends_on: []
environment:
EDITION: oss
image_pull_secrets:
- gcr
- gar
kind: pipeline
name: release-test-frontend
node:
type: no-parallel
platform:
arch: amd64
os: linux
services: []
steps:
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.20.3
name: identify-runner
- commands:
- yarn install --immutable || yarn install --immutable
depends_on: []
image: node:20.9.0-alpine
name: yarn-install
- commands:
- apk add --update git bash
- yarn betterer ci
depends_on:
- yarn-install
image: node:20.9.0-alpine
name: betterer-frontend
- commands:
- yarn run ci:test-frontend
depends_on:
- yarn-install
environment:
TEST_MAX_WORKERS: 50%
image: node:20.9.0-alpine
name: test-frontend
trigger:
event:
exclude:
- promote
ref:
exclude:
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
---
clone:
retries: 3
depends_on: []
environment:
EDITION: oss
image_pull_secrets:
- gcr
- gar
kind: pipeline
name: release-test-backend
node:
type: no-parallel
platform:
arch: amd64
os: linux
services: []
steps:
- commands:
- echo $DRONE_RUNNER_NAME
image: alpine:3.20.3
name: identify-runner
- commands:
- '# It is required that code generated from Thema/CUE be committed and in sync
with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- apk add --update make
- CODEGEN_VERIFY=1 make gen-cue
depends_on: []
image: golang:1.23.1-alpine
name: verify-gen-cue
- commands:
- '# It is required that generated jsonnet is committed and in sync with its inputs.'
- '# The following command will fail if running code generators produces any diff
in output.'
- apk add --update make
- CODEGEN_VERIFY=1 make gen-jsonnet
depends_on: []
image: golang:1.23.1-alpine
name: verify-gen-jsonnet
- commands:
- apk add --update make
- make gen-go
depends_on:
- verify-gen-cue
image: golang:1.23.1-alpine
name: wire-install
- commands:
- apk add --update build-base shared-mime-info shared-mime-info-lang
- go list -f '{{.Dir}}/...' -m | xargs go test -short -covermode=atomic -timeout=5m
depends_on:
- wire-install
image: golang:1.23.1-alpine
name: test-backend
- commands:
- apk add --update build-base
- go test -count=1 -covermode=atomic -timeout=5m -run '^TestIntegration' $(find
./pkg -type f -name '*_test.go' -exec grep -l '^func TestIntegration' '{}' '+'
| grep -o '\(.*\)/' | sort -u)
depends_on:
- wire-install
image: golang:1.23.1-alpine
name: test-backend-integration
trigger:
event:
exclude:
- promote
ref:
exclude:
- refs/tags/*-cloud*
include:
- refs/tags/v*
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
---
clone:
retries: 3
depends_on:
- release-test-backend
- release-test-frontend
image_pull_secrets: image_pull_secrets:
- gcr - gcr
- gar - gar
@@ -4648,9 +4512,7 @@ volumes:
--- ---
clone: clone:
retries: 3 retries: 3
depends_on: depends_on: []
- release-test-backend
- release-test-frontend
image_pull_secrets: image_pull_secrets:
- gcr - gcr
- gar - gar
@@ -6151,6 +6013,6 @@ kind: secret
name: gcr_credentials name: gcr_credentials
--- ---
kind: signature kind: signature
hmac: 766cd43d479f82bdb5bbaa3b48ed87ad13ea71d3418deb5d0c89ec7b77ae0475 hmac: e618274ea7a8bfbf3d5e151d459348aa9382fe63fe7fef76c997db3cba74779f
... ...
+1
View File
@@ -298,6 +298,7 @@
/pkg/modules/ @grafana/grafana-app-platform-squad /pkg/modules/ @grafana/grafana-app-platform-squad
/pkg/services/grpcserver/ @grafana/grafana-search-and-storage /pkg/services/grpcserver/ @grafana/grafana-search-and-storage
/pkg/generated @grafana/grafana-app-platform-squad /pkg/generated @grafana/grafana-app-platform-squad
/pkg/services/unifiedSearch/ @grafana/grafana-search-and-storage
# Alerting # Alerting
/pkg/services/ngalert/ @grafana/alerting-backend /pkg/services/ngalert/ @grafana/alerting-backend
@@ -2,6 +2,10 @@
--- ---
name: Levitate / Detect breaking changes in PR name: Levitate / Detect breaking changes in PR
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on: on:
pull_request: pull_request:
paths: paths:
@@ -11,7 +15,7 @@ on:
jobs: jobs:
buildPR: buildPR:
name: Build PR name: Build PR packages artifacts
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@@ -27,7 +31,7 @@ jobs:
- name: Get yarn cache directory path - name: Get yarn cache directory path
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn cache - name: Restore yarn cache
uses: actions/cache@v4 uses: actions/cache@v4
@@ -57,7 +61,7 @@ jobs:
path: './pr/pr_built_packages.zip' path: './pr/pr_built_packages.zip'
buildBase: buildBase:
name: Build Base name: Build Base packages artifacts
runs-on: ubuntu-latest runs-on: ubuntu-latest
defaults: defaults:
run: run:
@@ -75,7 +79,7 @@ jobs:
- name: Get yarn cache directory path - name: Get yarn cache directory path
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT"
- name: Restore yarn cache - name: Restore yarn cache
uses: actions/cache@v4 uses: actions/cache@v4
@@ -105,7 +109,7 @@ jobs:
path: './base/base_built_packages.zip' path: './base/base_built_packages.zip'
Detect: Detect:
name: Detect breaking changes name: Detect breaking changes between PR and base
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: ['buildPR', 'buildBase'] needs: ['buildPR', 'buildBase']
env: env:
@@ -179,7 +183,7 @@ jobs:
Report: Report:
name: Report breaking changes in PR name: Report breaking changes in PR comment
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: ['Detect'] needs: ['Detect']
@@ -234,9 +238,9 @@ jobs:
echo 'levitate_markdown<<EOF' echo 'levitate_markdown<<EOF'
cat levitate.md cat levitate.md
echo EOF echo EOF
} >> $GITHUB_OUTPUT } >> "$GITHUB_OUTPUT"
else else
echo "levitate_markdown=No breaking changes detected" >> $GITHUB_OUTPUT echo "levitate_markdown=No breaking changes detected" >> "$GITHUB_OUTPUT"
fi fi
@@ -253,7 +257,6 @@ jobs:
${{ steps.levitate-markdown.outputs.levitate_markdown }} ${{ steps.levitate-markdown.outputs.levitate_markdown }}
[Read our guideline](https://github.com/grafana/grafana/blob/main/contribute/breaking-changes-guide/breaking-changes-guide.md) [Read our guideline](https://github.com/grafana/grafana/blob/main/contribute/breaking-changes-guide/breaking-changes-guide.md)
[Console output](${{ steps.levitate-run.outputs.job_link }})
* Your pull request merge won't be blocked. * Your pull request merge won't be blocked.
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }} GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
@@ -359,5 +362,6 @@ jobs:
if [ "${{ steps.levitate-run.outputs.exit_code }}" -ne 0 ]; then if [ "${{ steps.levitate-run.outputs.exit_code }}" -ne 0 ]; then
echo "Breaking changes detected. Please check the levitate report in your pull request. This workflow won't block merging." echo "Breaking changes detected. Please check the levitate report in your pull request. This workflow won't block merging."
fi fi
exit ${{ steps.levitate-run.outputs.exit_code }} exit ${{ steps.levitate-run.outputs.exit_code }}
shell: bash shell: bash
+12
View File
@@ -18,6 +18,9 @@ GO_BUILD_FLAGS += $(if $(GO_BUILD_DEV),-dev)
GO_BUILD_FLAGS += $(if $(GO_BUILD_TAGS),-build-tags=$(GO_BUILD_TAGS)) GO_BUILD_FLAGS += $(if $(GO_BUILD_TAGS),-build-tags=$(GO_BUILD_TAGS))
GO_BUILD_FLAGS += $(GO_RACE_FLAG) GO_BUILD_FLAGS += $(GO_RACE_FLAG)
# GNU xargs has flag -r, and BSD xargs (e.g. MacOS) has that behaviour by default
XARGSR = $(shell xargs --version 2>&1 | grep -q GNU && echo xargs -r || echo xargs)
targets := $(shell echo '$(sources)' | tr "," " ") targets := $(shell echo '$(sources)' | tr "," " ")
GO_INTEGRATION_TESTS := $(shell find ./pkg -type f -name '*_test.go' -exec grep -l '^func TestIntegration' '{}' '+' | grep -o '\(.*\)/' | sort -u) GO_INTEGRATION_TESTS := $(shell find ./pkg -type f -name '*_test.go' -exec grep -l '^func TestIntegration' '{}' '+' | grep -o '\(.*\)/' | sort -u)
@@ -303,6 +306,15 @@ golangci-lint: $(GOLANGCI_LINT)
.PHONY: lint-go .PHONY: lint-go
lint-go: golangci-lint ## Run all code checks for backend. You can use GO_LINT_FILES to specify exact files to check lint-go: golangci-lint ## Run all code checks for backend. You can use GO_LINT_FILES to specify exact files to check
.PHONY: lint-go-diff
lint-go-diff: $(GOLANGCI_LINT)
git diff --name-only remotes/origin/main | \
grep '\.go$$' | \
$(XARGSR) dirname | \
sort -u | \
sed 's,^,./,' | \
$(XARGSR) $(GOLANGCI_LINT) run --config .golangci.toml
# with disabled SC1071 we are ignored some TCL,Expect `/usr/bin/env expect` scripts # with disabled SC1071 we are ignored some TCL,Expect `/usr/bin/env expect` scripts
.PHONY: shellcheck .PHONY: shellcheck
shellcheck: $(SH_FILES) ## Run checks for shell scripts. shellcheck: $(SH_FILES) ## Run checks for shell scripts.
+7 -2
View File
@@ -1346,6 +1346,11 @@ notification_log_retention = 5d
# Duration for which a resolved alert state transition will continue to be sent to the Alertmanager. # Duration for which a resolved alert state transition will continue to be sent to the Alertmanager.
resolved_alert_retention = 15m resolved_alert_retention = 15m
# Defines the limit of how many alert rule versions
# should be stored in the database for each alert rule in an organization including the current one.
# 0 value means no limit
rule_version_record_limit = 0
[unified_alerting.screenshots] [unified_alerting.screenshots]
# Enable screenshots in notifications. You must have either installed the Grafana image rendering # Enable screenshots in notifications. You must have either installed the Grafana image rendering
# plugin, or set up Grafana to use a remote rendering service. # plugin, or set up Grafana to use a remote rendering service.
@@ -1560,8 +1565,8 @@ expire_time = 7
#################################### Internal Grafana Metrics ############ #################################### Internal Grafana Metrics ############
# Metrics available at HTTP URL /metrics and /metrics/plugins/:pluginId # Metrics available at HTTP URL /metrics and /metrics/plugins/:pluginId
[metrics] [metrics]
enabled = true enabled = true
interval_seconds = 10 interval_seconds = 10
# Disable total stats (stat_totals_*) metrics to be generated # Disable total stats (stat_totals_*) metrics to be generated
disable_total_stats = false disable_total_stats = false
# The interval at which the total stats collector will update the stats. Default is 1800 seconds. # The interval at which the total stats collector will update the stats. Default is 1800 seconds.
+5
View File
@@ -1335,6 +1335,11 @@
# Duration for which a resolved alert state transition will continue to be sent to the Alertmanager. # Duration for which a resolved alert state transition will continue to be sent to the Alertmanager.
;resolved_alert_retention = 15m ;resolved_alert_retention = 15m
# Defines the limit of how many alert rule versions
# should be stored in the database for each alert rule in an organization including the current one.
# 0 value means no limit
;rule_version_record_limit= 0
[unified_alerting.screenshots] [unified_alerting.screenshots]
# Enable screenshots in notifications. You must have either installed the Grafana image rendering # Enable screenshots in notifications. You must have either installed the Grafana image rendering
# plugin, or set up Grafana to use a remote rendering service. # plugin, or set up Grafana to use a remote rendering service.
@@ -566,6 +566,7 @@ Lists the roles that have been directly assigned to a given user. The list does
Query Parameters: Query Parameters:
- `includeHidden`: Optional. Set to `true` to include roles that are `hidden`. - `includeHidden`: Optional. Set to `true` to include roles that are `hidden`.
- `includeMapped`: Optional. Set to `true` to include roles that have been mapped through the group attribute sync feature.
#### Required permissions #### Required permissions
@@ -104,6 +104,7 @@ With a Grafana Enterprise license, you also get access to premium data sources,
- [Sqlyze Datasource](/grafana/plugins/grafana-odbc-datasource) - [Sqlyze Datasource](/grafana/plugins/grafana-odbc-datasource)
- [SumoLogic](/grafana/plugins/grafana-sumologic-datasource) - [SumoLogic](/grafana/plugins/grafana-sumologic-datasource)
- [Wavefront](/grafana/plugins/grafana-wavefront-datasource) - [Wavefront](/grafana/plugins/grafana-wavefront-datasource)
- [Zendesk](/grafana/plugins/grafana-zendesk-datasource)
## Try Grafana Enterprise ## Try Grafana Enterprise
@@ -48,7 +48,6 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `angularDeprecationUI` | Display Angular warnings in dashboards and panels | Yes | | `angularDeprecationUI` | Display Angular warnings in dashboards and panels | Yes |
| `dashgpt` | Enable AI powered features in dashboards | Yes | | `dashgpt` | Enable AI powered features in dashboards | Yes |
| `alertingInsights` | Show the new alerting insights landing page | Yes | | `alertingInsights` | Show the new alerting insights landing page | Yes |
| `externalServiceAccounts` | Automatic service account and token setup for plugins | Yes |
| `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes | | `panelMonitoring` | Enables panel monitoring through logs and measurements | Yes |
| `formatString` | Enable format string transformer | Yes | | `formatString` | Enable format string transformer | Yes |
| `transformationsVariableSupport` | Allows using variables in transformations | Yes | | `transformationsVariableSupport` | Allows using variables in transformations | Yes |
@@ -101,6 +100,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel | | `enableDatagridEditing` | Enables the edit functionality in the datagrid panel |
| `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior | | `sqlDatasourceDatabaseSelection` | Enables previous SQL data source dataset dropdown behavior |
| `reportingRetries` | Enables rendering retries for the reporting feature | | `reportingRetries` | Enables rendering retries for the reporting feature |
| `externalServiceAccounts` | Automatic service account and token setup for plugins |
| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | | `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches |
| `teamHttpHeaders` | Enables LBAC for datasources to apply LogQL filtering of logs to the client requests for users in teams | | `teamHttpHeaders` | Enables LBAC for datasources to apply LogQL filtering of logs to the client requests for users in teams |
| `pdfTables` | Enables generating table data as PDF in reporting | | `pdfTables` | Enables generating table data as PDF in reporting |
@@ -111,6 +111,7 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general-
| `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector | | `ssoSettingsSAML` | Use the new SSO Settings API to configure the SAML connector |
| `accessActionSets` | Introduces action sets for resource permissions. Also ensures that all folder editors and admins can create subfolders without needing any additional permissions. | | `accessActionSets` | Introduces action sets for resource permissions. Also ensures that all folder editors and admins can create subfolders without needing any additional permissions. |
| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | | `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars |
| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP |
| `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder | | `cloudwatchMetricInsightsCrossAccount` | Enables cross account observability for Cloudwatch Metric Insights query builder |
| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | | `useSessionStorageForRedirection` | Use session storage for handling the redirection after login |
@@ -181,6 +182,7 @@ Experimental features might be changed or removed without prior notice.
| `tableSharedCrosshair` | Enables shared crosshair in table panel | | `tableSharedCrosshair` | Enables shared crosshair in table panel |
| `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend | | `kubernetesFeatureToggles` | Use the kubernetes API for feature toggle management in the frontend |
| `newFolderPicker` | Enables the nested folder picker without having nested folders enabled | | `newFolderPicker` | Enables the nested folder picker without having nested folders enabled |
| `onPremToCloudMigrationsAlerts` | Enables the migration of alerts and its child resources to your Grafana Cloud stack. Requires `onPremToCloudMigrations` to be enabled in conjunction. |
| `sqlExpressions` | Enables using SQL and DuckDB functions as Expressions. | | `sqlExpressions` | Enables using SQL and DuckDB functions as Expressions. |
| `nodeGraphDotLayout` | Changed the layout algorithm for the node graph | | `nodeGraphDotLayout` | Changed the layout algorithm for the node graph |
| `kubernetesAggregator` | Enable grafana's embedded kube-aggregator | | `kubernetesAggregator` | Enable grafana's embedded kube-aggregator |
@@ -206,6 +208,7 @@ Experimental features might be changed or removed without prior notice.
| `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time | | `appSidecar` | Enable the app sidecar feature that allows rendering 2 apps at the same time |
| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | | `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions |
| `rolePickerDrawer` | Enables the new role picker drawer design | | `rolePickerDrawer` | Enables the new role picker drawer design |
| `pluginsSriChecks` | Enables SRI checks for plugin assets |
## Development feature toggles ## Development feature toggles
@@ -53,6 +53,12 @@ role_attribute_path = contains(roles[*], 'admin') && 'Admin' || contains(roles[*
As an example, `<PROVIDER_DOMAIN>` can be `keycloak-demo.grafana.org` As an example, `<PROVIDER_DOMAIN>` can be `keycloak-demo.grafana.org`
and `<REALM_NAME>` can be `grafana`. and `<REALM_NAME>` can be `grafana`.
To configure the `kc_idp_hint` parameter for Keycloak, you need to change the `auth_url` configuration to include the `kc_idp_hint` parameter. For example if you want to hint the Google identity provider:
```ini
auth_url = https://<PROVIDER_DOMAIN>/realms/<REALM_NAME>/protocol/openid-connect/auth?kc_idp_hint=google
```
{{% admonition type="note" %}} {{% admonition type="note" %}}
api_url is not required if the id_token contains all the necessary user information and can add latency to the login process. api_url is not required if the id_token contains all the necessary user information and can add latency to the login process.
It is useful as a fallback or if the user has more than 150 group memberships. It is useful as a fallback or if the user has more than 150 group memberships.
+33 -19
View File
@@ -2,6 +2,8 @@ import { Interception } from 'cypress/types/net-stubbing';
import { load } from 'js-yaml'; import { load } from 'js-yaml';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { selectors as rawSelectors } from '@grafana/e2e-selectors';
import { selectors } from '../../public/app/plugins/datasource/azuremonitor/e2e/selectors'; import { selectors } from '../../public/app/plugins/datasource/azuremonitor/e2e/selectors';
import { import {
AzureDataSourceJsonData, AzureDataSourceJsonData,
@@ -75,12 +77,13 @@ const addAzureMonitorVariable = (
isFirst: boolean, isFirst: boolean,
options?: { subscription?: string; resourceGroup?: string; namespace?: string; resource?: string; region?: string } options?: { subscription?: string; resourceGroup?: string; namespace?: string; resource?: string; region?: string }
) => { ) => {
e2e.components.PageToolbar.item('Dashboard settings').click(); e2e.components.NavToolbar.editDashboard.editButton().should('be.visible').click();
e2e.components.NavToolbar.editDashboard.settingsButton().should('be.visible').click();
e2e.components.Tab.title('Variables').click(); e2e.components.Tab.title('Variables').click();
if (isFirst) { if (isFirst) {
e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2().click(); e2e.pages.Dashboard.Settings.Variables.List.addVariableCTAV2().click();
} else { } else {
e2e.pages.Dashboard.Settings.Variables.List.newButton().click(); cy.get(`[data-testid="${rawSelectors.pages.Dashboard.Settings.Variables.List.newButton}"]`).click();
} }
e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type(name); e2e.pages.Dashboard.Settings.Variables.Edit.General.generalNameInputV2().clear().type(name);
e2e.components.DataSourcePicker.inputV2().type(`${dataSourceName}{enter}`); e2e.components.DataSourcePicker.inputV2().type(`${dataSourceName}{enter}`);
@@ -113,7 +116,8 @@ const addAzureMonitorVariable = (
break; break;
} }
e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click(); e2e.pages.Dashboard.Settings.Variables.Edit.General.submitButton().click();
e2e.pages.Dashboard.Settings.Actions.close().click(); e2e.components.NavToolbar.editDashboard.backToDashboardButton().click();
e2e.components.NavToolbar.editDashboard.exitButton().click();
}; };
const storageAcctName = 'azmonteststorage'; const storageAcctName = 'azmonteststorage';
@@ -189,7 +193,8 @@ describe('Azure monitor datasource', () => {
}, },
timeout: 10000, timeout: 10000,
}); });
e2e.components.PanelEditor.applyButton().click(); e2e.components.NavToolbar.editDashboard.backToDashboardButton().click();
e2e.components.NavToolbar.editDashboard.exitButton().click();
e2e.flows.addPanel({ e2e.flows.addPanel({
dataSourceName, dataSourceName,
visitDashboardAtStart: false, visitDashboardAtStart: false,
@@ -209,7 +214,8 @@ describe('Azure monitor datasource', () => {
}, },
timeout: 10000, timeout: 10000,
}); });
e2e.components.PanelEditor.applyButton().click(); e2e.components.NavToolbar.editDashboard.backToDashboardButton().click();
e2e.components.NavToolbar.editDashboard.exitButton().click();
e2e.flows.addPanel({ e2e.flows.addPanel({
dataSourceName, dataSourceName,
visitDashboardAtStart: false, visitDashboardAtStart: false,
@@ -228,7 +234,8 @@ describe('Azure monitor datasource', () => {
}, },
timeout: 10000, timeout: 10000,
}); });
e2e.components.PanelEditor.applyButton().click(); e2e.components.NavToolbar.editDashboard.backToDashboardButton().click();
e2e.components.NavToolbar.editDashboard.exitButton().click();
e2e.flows.addPanel({ e2e.flows.addPanel({
dataSourceName, dataSourceName,
visitDashboardAtStart: false, visitDashboardAtStart: false,
@@ -275,25 +282,32 @@ describe('Azure monitor datasource', () => {
namespace: '$namespace', namespace: '$namespace',
region: '$region', region: '$region',
}); });
e2e.pages.Dashboard.SubMenu.submenuItemLabels('subscription').click(); e2e.pages.Dashboard.SubMenu.submenuItemLabels('subscription')
e2e.pages.Dashboard.SubMenu.submenuItemValueDropDownOptionTexts('grafanalabs-datasources-dev').click(); .parent()
e2e.pages.Dashboard.SubMenu.submenuItemLabels('resourceGroups').parent().find('button').click(); .within(() => {
cy.get('input').click();
});
e2e.components.Select.option().contains('grafanalabs-datasources-dev').click();
e2e.pages.Dashboard.SubMenu.submenuItemLabels('resourceGroups') e2e.pages.Dashboard.SubMenu.submenuItemLabels('resourceGroups')
.parent() .parent()
.find('input') .within(() => {
.type('cloud-plugins-e2e-test-azmon{downArrow}{enter}'); cy.get('input').type('cloud-plugins-e2e-test-azmon{downArrow}{enter}');
e2e.pages.Dashboard.SubMenu.submenuItemLabels('namespaces').parent().find('button').click(); });
e2e.pages.Dashboard.SubMenu.submenuItemLabels('namespaces') e2e.pages.Dashboard.SubMenu.submenuItemLabels('namespaces')
.parent() .parent()
.find('input') .within(() => {
.type('microsoft.storage/storageaccounts{downArrow}{enter}'); cy.get('input').type('microsoft.storage/storageaccounts{downArrow}{enter}');
e2e.pages.Dashboard.SubMenu.submenuItemLabels('region').parent().find('button').click(); });
e2e.pages.Dashboard.SubMenu.submenuItemLabels('region').parent().find('input').type('uk south{downArrow}{enter}'); e2e.pages.Dashboard.SubMenu.submenuItemLabels('region')
e2e.pages.Dashboard.SubMenu.submenuItemLabels('resource').parent().find('button').click(); .parent()
.within(() => {
cy.get('input').type('uk south{downArrow}{enter}');
});
e2e.pages.Dashboard.SubMenu.submenuItemLabels('resource') e2e.pages.Dashboard.SubMenu.submenuItemLabels('resource')
.parent() .parent()
.find('input') .within(() => {
.type(`${storageAcctName}{downArrow}{enter}`); cy.get('input').type(`${storageAcctName}{downArrow}{enter}`);
});
e2e.flows.addPanel({ e2e.flows.addPanel({
dataSourceName, dataSourceName,
visitDashboardAtStart: false, visitDashboardAtStart: false,
@@ -34,7 +34,7 @@ describe('Dashboard templating', () => {
`Server:sqlstring = 'A''A\\"A','BB\\\B','CCC'`, `Server:sqlstring = 'A''A\\"A','BB\\\B','CCC'`,
`Server:date = NaN`, `Server:date = NaN`,
`Server:text = All`, `Server:text = All`,
`Server:queryparam = var-Server=A%27A%22A&var-Server=BB%5CB&var-Server=CCC`, `Server:queryparam = var-Server=$__all`,
`1 < 2`, `1 < 2`,
`Example: from=now-6h&to=now`, `Example: from=now-6h&to=now`,
]; ];
@@ -34,7 +34,7 @@ describe('Templating', () => {
e2e.components.DashboardLinks.dropDown().should('be.visible').click().wait('@tagsTemplatingSearch'); e2e.components.DashboardLinks.dropDown().should('be.visible').click().wait('@tagsTemplatingSearch');
verifyLinks('var-custom=%24__all'); verifyLinks('var-custom=$__all');
cy.get('body').click(); cy.get('body').click();
+2 -1
View File
@@ -49,7 +49,8 @@ export const importDashboard = (dashboardToImport: Dashboard, queryTimeout?: num
dashboardToImport.panels.forEach((panel) => { dashboardToImport.panels.forEach((panel) => {
// Look at the json data // Look at the json data
e2e.components.Panels.Panel.menu(panel.title).click({ force: true }); // force click because menu is hidden and show on hover e2e.components.Panels.Panel.menu(panel.title).click({ force: true }); // force click because menu is hidden and show on hover
e2e.components.Panels.Panel.menuItems('Inspect').should('be.visible').click(); e2e.components.Panels.Panel.menuItems('Inspect').trigger('mouseover', { force: true });
e2e.components.Panels.Panel.menuItems('Data').click({ force: true });
e2e.components.Tab.title('JSON').should('be.visible').click(); e2e.components.Tab.title('JSON').should('be.visible').click();
e2e.components.PanelInspector.Json.content().should('be.visible').contains('Panel JSON').click({ force: true }); e2e.components.PanelInspector.Json.content().should('be.visible').contains('Panel JSON').click({ force: true });
e2e.components.Select.option().should('be.visible').contains('Panel data').click(); e2e.components.Select.option().should('be.visible').contains('Panel data').click();
@@ -34,8 +34,10 @@ describe('Inspect drawer tests', () => {
e2e.flows.openDashboard({ uid: 'wfTJJL5Wz' }); e2e.flows.openDashboard({ uid: 'wfTJJL5Wz' });
// testing opening inspect drawer directly by clicking on Inspect in header menu e2e.components.Panels.Panel.title(PANEL_UNDER_TEST).scrollIntoView().should('be.visible');
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Inspect, PANEL_UNDER_TEST); e2e.components.Panels.Panel.menu(PANEL_UNDER_TEST).click({ force: true }); // force click because menu is hidden and show on hover
e2e.components.Panels.Panel.menuItems('Inspect').trigger('mouseover', { force: true });
e2e.components.Panels.Panel.menuItems('Data').click({ force: true });
expectDrawerTabsAndContent(); expectDrawerTabsAndContent();
+2 -1
View File
@@ -49,7 +49,8 @@ export const importDashboard = (dashboardToImport: Dashboard, queryTimeout?: num
dashboardToImport.panels.forEach((panel) => { dashboardToImport.panels.forEach((panel) => {
// Look at the json data // Look at the json data
e2e.components.Panels.Panel.menu(panel.title).click({ force: true }); // force click because menu is hidden and show on hover e2e.components.Panels.Panel.menu(panel.title).click({ force: true }); // force click because menu is hidden and show on hover
e2e.components.Panels.Panel.menuItems('Inspect').should('be.visible').click(); e2e.components.Panels.Panel.menuItems('Inspect').trigger('mouseover', { force: true });
e2e.components.Panels.Panel.menuItems('Data').click({ force: true });
e2e.components.Tab.title('JSON').should('be.visible').click(); e2e.components.Tab.title('JSON').should('be.visible').click();
e2e.components.PanelInspector.Json.content().should('be.visible'); e2e.components.PanelInspector.Json.content().should('be.visible');
e2e.components.ReactMonacoEditor.editorLazy().should('be.visible'); e2e.components.ReactMonacoEditor.editorLazy().should('be.visible');
+4 -1
View File
@@ -5,7 +5,10 @@ import { fromBaseUrl } from '../support/url';
const defaultUserPreferences = { const defaultUserPreferences = {
timezone: '', // "Default" option timezone: '', // "Default" option
} as const; // TODO: when we update typescript >4.9 change to `as const satisfies UserPreferencesDTO` navbar: {
bookmarkUrls: [],
},
} as const satisfies UserPreferencesDTO; // TODO: when we update typescript >4.9 change to `as const satisfies UserPreferencesDTO`
// Only accept preferences we have defaults for as arguments. To allow a new preference to be set, add a default for it // Only accept preferences we have defaults for as arguments. To allow a new preference to be set, add a default for it
type UserPreferences = Pick<UserPreferencesDTO, keyof typeof defaultUserPreferences>; type UserPreferences = Pick<UserPreferencesDTO, keyof typeof defaultUserPreferences>;
+62
View File
@@ -0,0 +1,62 @@
import { e2e } from '../utils';
import { fromBaseUrl } from '../utils/support/url';
describe('Pin nav items', () => {
beforeEach(() => {
cy.viewport(1280, 800);
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
cy.visit(fromBaseUrl('/'));
});
afterEach(() => {
e2e.flows.setDefaultUserPreferences();
});
it('should pin the selected menu item and add it as a Bookmarks menu item child', () => {
// Open, dock and check if the mega menu is visible
cy.get('[aria-label="Open menu"]').click();
cy.get('[aria-label="Dock menu"]').click();
e2e.components.NavMenu.Menu().should('be.visible');
// Check if the Bookmark section is visible
const bookmarkSection = cy.get('[href="/bookmarks"]');
bookmarkSection.should('be.visible');
// Click on the pin icon to add Administration to the Bookmarks section
const adminItem = cy.contains('a', 'Administration');
const bookmarkPinIcon = adminItem.siblings('button').should('have.attr', 'aria-label', 'Add to Bookmarks');
bookmarkPinIcon.click({ force: true });
// Check if the Administration menu item is visible in the Bookmarks section
cy.get('[aria-label="Expand section Bookmarks"]').click();
const bookmarks = cy.get('[href="/bookmarks"]').parentsUntil('li').siblings('ul');
bookmarks.within(() => {
cy.get('a').should('contain.text', 'Administration');
});
});
it('should unpin the item and remove it from the Bookmarks section', () => {
// Set Administration as a pinned item and reload the page
e2e.flows.setUserPreferences({ navbar: { bookmarkUrls: ['/admin'] } });
cy.reload();
// Open, dock and check if the mega menu is visible
cy.get('[aria-label="Open menu"]').click();
cy.get('[aria-label="Dock menu"]').click();
e2e.components.NavMenu.Menu().should('be.visible');
// Check if the Bookmark section is visible and open it
cy.get('[href="/bookmarks"]').should('be.visible');
cy.get('[aria-label="Expand section Bookmarks"]').click();
// Check if the Administration menu item is visible in the Bookmarks section
const bookmarks = cy.get('[href="/bookmarks"]').parentsUntil('li').siblings('ul').children();
const administrationIsPinned = bookmarks.filter('li').children().should('contain.text', 'Administration');
// Click on the pin icon to remove Administration from the Bookmarks section and check if it is removed
administrationIsPinned.within(() => {
cy.get('[aria-label="Remove from Bookmarks"]').click({ force: true });
});
cy.wait(500);
administrationIsPinned.should('not.exist');
});
});
+4 -2
View File
@@ -34,8 +34,10 @@ describe('Inspect drawer tests', () => {
e2e.flows.openDashboard({ uid: 'wfTJJL5Wz' }); e2e.flows.openDashboard({ uid: 'wfTJJL5Wz' });
// testing opening inspect drawer directly by clicking on Inspect in header menu e2e.components.Panels.Panel.title(PANEL_UNDER_TEST).scrollIntoView().should('be.visible');
e2e.flows.openPanelMenuItem(e2e.flows.PanelMenuItems.Inspect, PANEL_UNDER_TEST); e2e.components.Panels.Panel.menu(PANEL_UNDER_TEST).click({ force: true }); // force click because menu is hidden and show on hover
e2e.components.Panels.Panel.menuItems('Inspect').trigger('mouseover', { force: true });
e2e.components.Panels.Panel.menuItems('Data').click({ force: true });
expectDrawerTabsAndContent(); expectDrawerTabsAndContent();
+5 -5
View File
@@ -378,7 +378,6 @@ github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:9
github.com/blevesearch/goleveldb v1.0.1 h1:iAtV2Cu5s0GD1lwUiekkFHe2gTMCCNVj2foPclDLIFI= github.com/blevesearch/goleveldb v1.0.1 h1:iAtV2Cu5s0GD1lwUiekkFHe2gTMCCNVj2foPclDLIFI=
github.com/blevesearch/goleveldb v1.0.1/go.mod h1:WrU8ltZbIp0wAoig/MHbrPCXSOLpe79nz5lv5nqfYrQ= github.com/blevesearch/goleveldb v1.0.1/go.mod h1:WrU8ltZbIp0wAoig/MHbrPCXSOLpe79nz5lv5nqfYrQ=
github.com/blevesearch/mmap-go v1.0.3/go.mod h1:pYvKl/grLQrBxuaRYgoTssa4rVujYYeenDp++2E+yvs= github.com/blevesearch/mmap-go v1.0.3/go.mod h1:pYvKl/grLQrBxuaRYgoTssa4rVujYYeenDp++2E+yvs=
github.com/blevesearch/segment v0.9.0 h1:5lG7yBCx98or7gK2cHMKPukPZ/31Kag7nONpoBt22Ac=
github.com/blevesearch/snowball v0.6.1 h1:cDYjn/NCH+wwt2UdehaLpr2e4BwLIjN4V/TdLsL+B5A= github.com/blevesearch/snowball v0.6.1 h1:cDYjn/NCH+wwt2UdehaLpr2e4BwLIjN4V/TdLsL+B5A=
github.com/blevesearch/snowball v0.6.1/go.mod h1:ZF0IBg5vgpeoUhnMza2v0A/z8m1cWPlwhke08LpNusg= github.com/blevesearch/snowball v0.6.1/go.mod h1:ZF0IBg5vgpeoUhnMza2v0A/z8m1cWPlwhke08LpNusg=
github.com/blevesearch/stempel v0.2.0 h1:CYzVPaScODMvgE9o+kf6D4RJ/VRomyi9uHF+PtB+Afc= github.com/blevesearch/stempel v0.2.0 h1:CYzVPaScODMvgE9o+kf6D4RJ/VRomyi9uHF+PtB+Afc=
@@ -491,8 +490,6 @@ github.com/elastic/go-sysinfo v1.11.2/go.mod h1:GKqR8bbMK/1ITnez9NIsIfXQr25aLhRJ
github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0= github.com/elastic/go-windows v1.0.1 h1:AlYZOldA+UJ0/2nBuqWdo90GFCgG9xuyw9SYzGUtJm0=
github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss= github.com/elastic/go-windows v1.0.1/go.mod h1:FoVvqWSun28vaDQPbj2Elfc0JahhPB7WQEGa3c814Ss=
github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8=
github.com/ettle/strcase v0.1.1 h1:htFueZyVeE1XNnMEfbqp5r67qAN/4r6ya1ysq8Q+Zcw=
github.com/expr-lang/expr v1.16.2 h1:JvMnzUs3LeVHBvGFcXYmXo+Q6DPDmzrlcSBO6Wy3w4s= github.com/expr-lang/expr v1.16.2 h1:JvMnzUs3LeVHBvGFcXYmXo+Q6DPDmzrlcSBO6Wy3w4s=
github.com/expr-lang/expr v1.16.2/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= github.com/expr-lang/expr v1.16.2/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@@ -587,8 +584,6 @@ github.com/grafana/alerting v0.0.0-20240830172655-aa466962ea18 h1:3cQ+d+fkNL2Eqp
github.com/grafana/alerting v0.0.0-20240830172655-aa466962ea18/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4= github.com/grafana/alerting v0.0.0-20240830172655-aa466962ea18/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4=
github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10 h1:oDbLKM34O+JUF9EQFS+9aYhdYoeNfUpXqNjFCLIxwF4= github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10 h1:oDbLKM34O+JUF9EQFS+9aYhdYoeNfUpXqNjFCLIxwF4=
github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4= github.com/grafana/alerting v0.0.0-20240917171353-6c25eb6eff10/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4=
github.com/grafana/alerting v0.0.0-20240926233713-446ddd356f8d h1:HOK6RWTuVldWFtNbWHxPlTa2shZ+WsNJsxoRJhX56Zg=
github.com/grafana/alerting v0.0.0-20240926233713-446ddd356f8d/go.mod h1:GMLi6d09Xqo96fCVUjNk//rcjP5NKEdjOzfWIffD5r4=
github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56/go.mod h1:PGk3RjYHpxMM8HFPhKKo+vve3DdlPUELZLSDEFehPuU= github.com/grafana/gomemcache v0.0.0-20240229205252-cd6a66d6fb56/go.mod h1:PGk3RjYHpxMM8HFPhKKo+vve3DdlPUELZLSDEFehPuU=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45 h1:AJKOtDKAOg8XNFnIZSmqqqutoTSxVlRs6vekL2p2KEY= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45 h1:AJKOtDKAOg8XNFnIZSmqqqutoTSxVlRs6vekL2p2KEY=
github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45/go.mod h1:01sXtHoRwI8W324IPAzuxDFOmALqYLCOhvSC2fUHWXc= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240625192351-66ec17e3aa45/go.mod h1:01sXtHoRwI8W324IPAzuxDFOmALqYLCOhvSC2fUHWXc=
@@ -852,6 +847,7 @@ github.com/stoewer/parquet-cli v0.0.7/go.mod h1:bskxHdj8q3H1EmfuCqjViFoeO3NEvs5l
github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo= github.com/streadway/amqp v1.0.0 h1:kuuDrUJFZL1QYL9hUNuCxNObNzB0bV/ZG5jV3RWAQgo=
github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAHVdPR3IjfmN8T1h2iczJLynhLybf8= github.com/streadway/handy v0.0.0-20200128134331-0f66f006fb2e h1:mOtuXaRAbVZsxAHVdPR3IjfmN8T1h2iczJLynhLybf8=
github.com/substrait-io/substrait-go v0.4.2 h1:buDnjsb3qAqTaNbOR7VKmNgXf4lYQxWEcnSGUWBtmN8= github.com/substrait-io/substrait-go v0.4.2 h1:buDnjsb3qAqTaNbOR7VKmNgXf4lYQxWEcnSGUWBtmN8=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/tdewolff/minify/v2 v2.12.9 h1:dvn5MtmuQ/DFMwqf5j8QhEVpPX6fi3WGImhv8RUB4zA= github.com/tdewolff/minify/v2 v2.12.9 h1:dvn5MtmuQ/DFMwqf5j8QhEVpPX6fi3WGImhv8RUB4zA=
github.com/tdewolff/minify/v2 v2.12.9/go.mod h1:qOqdlDfL+7v0/fyymB+OP497nIxJYSvX4MQWA8OoiXU= github.com/tdewolff/minify/v2 v2.12.9/go.mod h1:qOqdlDfL+7v0/fyymB+OP497nIxJYSvX4MQWA8OoiXU=
github.com/tdewolff/parse/v2 v2.6.8 h1:mhNZXYCx//xG7Yq2e/kVLNZw4YfYmeHbhx+Zc0OvFMA= github.com/tdewolff/parse/v2 v2.6.8 h1:mhNZXYCx//xG7Yq2e/kVLNZw4YfYmeHbhx+Zc0OvFMA=
@@ -1037,9 +1033,11 @@ golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1051,6 +1049,7 @@ golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457 h1:zf5N6UOrA487eEFacMePxjXAJctxKmyjKUsjA11Uzuk=
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
@@ -1070,6 +1069,7 @@ google.golang.org/genproto/googleapis/api v0.0.0-20240730163845-b1a4ccb954bf/go.
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20240730163845-b1a4ccb954bf h1:T4tsZBlZYXK3j40sQNP5MBO32I+rn6ypV1PpklsiV8k= google.golang.org/genproto/googleapis/bytestream v0.0.0-20240730163845-b1a4ccb954bf h1:T4tsZBlZYXK3j40sQNP5MBO32I+rn6ypV1PpklsiV8k=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:5/MT647Cn/GGhwTpXC7QqcaR5Cnee4v4MKCU1/nwnIQ= google.golang.org/genproto/googleapis/bytestream v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:5/MT647Cn/GGhwTpXC7QqcaR5Cnee4v4MKCU1/nwnIQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240722135656-d784300faade/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
+23 -23
View File
@@ -69,9 +69,9 @@
"releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/" "releaseNotesUrl": "https://grafana.com/docs/grafana/next/release-notes/"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.25.2", "@babel/core": "7.25.7",
"@babel/preset-env": "7.25.4", "@babel/preset-env": "7.25.7",
"@babel/runtime": "7.25.6", "@babel/runtime": "7.25.7",
"@betterer/betterer": "5.4.0", "@betterer/betterer": "5.4.0",
"@betterer/cli": "5.4.0", "@betterer/cli": "5.4.0",
"@betterer/eslint": "5.4.0", "@betterer/eslint": "5.4.0",
@@ -82,11 +82,11 @@
"@grafana/plugin-e2e": "^1.8.3", "@grafana/plugin-e2e": "^1.8.3",
"@grafana/tsconfig": "^2.0.0", "@grafana/tsconfig": "^2.0.0",
"@manypkg/get-packages": "^2.2.0", "@manypkg/get-packages": "^2.2.0",
"@playwright/test": "1.47.2", "@playwright/test": "1.48.0",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.15", "@pmmmwh/react-refresh-webpack-plugin": "0.5.15",
"@react-types/button": "3.9.6", "@react-types/button": "3.9.6",
"@react-types/menu": "3.9.11", "@react-types/menu": "3.9.12",
"@react-types/overlays": "3.8.9", "@react-types/overlays": "3.8.10",
"@react-types/shared": "3.24.1", "@react-types/shared": "3.24.1",
"@rtk-query/codegen-openapi": "^1.2.0", "@rtk-query/codegen-openapi": "^1.2.0",
"@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1", "@rtsao/plugin-proposal-class-properties": "7.0.1-patch.1",
@@ -119,10 +119,10 @@
"@types/jquery": "3.5.31", "@types/jquery": "3.5.31",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.5",
"@types/jsurl": "^1.2.28", "@types/jsurl": "^1.2.28",
"@types/lodash": "4.17.9", "@types/lodash": "4.17.10",
"@types/logfmt": "^1.2.3", "@types/logfmt": "^1.2.3",
"@types/lucene": "^2", "@types/lucene": "^2",
"@types/node": "20.16.9", "@types/node": "20.16.11",
"@types/node-forge": "^1", "@types/node-forge": "^1",
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4", "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@3.2.4",
"@types/pluralize": "^0.0.33", "@types/pluralize": "^0.0.33",
@@ -181,7 +181,7 @@
"eslint-plugin-jsx-a11y": "6.10.0", "eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-lodash": "7.4.0", "eslint-plugin-lodash": "7.4.0",
"eslint-plugin-no-barrel-files": "^1.1.0", "eslint-plugin-no-barrel-files": "^1.1.0",
"eslint-plugin-react": "7.37.0", "eslint-plugin-react": "7.37.1",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-testing-library": "^6.2.2", "eslint-plugin-testing-library": "^6.2.2",
"eslint-scope": "^8.0.0", "eslint-scope": "^8.0.0",
@@ -198,7 +198,7 @@
"jest-canvas-mock": "2.5.2", "jest-canvas-mock": "2.5.2",
"jest-date-mock": "1.0.10", "jest-date-mock": "1.0.10",
"jest-environment-jsdom": "29.7.0", "jest-environment-jsdom": "29.7.0",
"jest-fail-on-console": "3.3.0", "jest-fail-on-console": "3.3.1",
"jest-junit": "16.0.0", "jest-junit": "16.0.0",
"jest-matcher-utils": "29.7.0", "jest-matcher-utils": "29.7.0",
"jest-watch-typeahead": "^2.2.2", "jest-watch-typeahead": "^2.2.2",
@@ -221,7 +221,7 @@
"react-test-renderer": "18.2.0", "react-test-renderer": "18.2.0",
"redux-mock-store": "1.5.4", "redux-mock-store": "1.5.4",
"rimraf": "6.0.1", "rimraf": "6.0.1",
"rudder-sdk-js": "2.48.18", "rudder-sdk-js": "2.48.19",
"sass": "1.79.3", "sass": "1.79.3",
"sass-loader": "16.0.2", "sass-loader": "16.0.2",
"smtp-tester": "^2.1.0", "smtp-tester": "^2.1.0",
@@ -246,17 +246,17 @@
"yargs": "^17.5.1" "yargs": "^17.5.1"
}, },
"dependencies": { "dependencies": {
"@emotion/css": "11.13.0", "@emotion/css": "11.13.4",
"@emotion/react": "11.13.3", "@emotion/react": "11.13.3",
"@fingerprintjs/fingerprintjs": "^3.4.2", "@fingerprintjs/fingerprintjs": "^3.4.2",
"@floating-ui/react": "0.26.24", "@floating-ui/react": "0.26.24",
"@formatjs/intl-durationformat": "^0.2.4", "@formatjs/intl-durationformat": "^0.2.4",
"@glideapps/glide-data-grid": "^6.0.0", "@glideapps/glide-data-grid": "^6.0.0",
"@grafana/aws-sdk": "0.4.2", "@grafana/aws-sdk": "0.5.0",
"@grafana/azure-sdk": "0.0.3", "@grafana/azure-sdk": "0.0.3",
"@grafana/data": "workspace:*", "@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*", "@grafana/e2e-selectors": "workspace:*",
"@grafana/experimental": "1.8.0", "@grafana/experimental": "2.1.1",
"@grafana/faro-core": "^1.3.6", "@grafana/faro-core": "^1.3.6",
"@grafana/faro-web-sdk": "^1.3.6", "@grafana/faro-web-sdk": "^1.3.6",
"@grafana/faro-web-tracing": "^1.8.2", "@grafana/faro-web-tracing": "^1.8.2",
@@ -268,14 +268,14 @@
"@grafana/prometheus": "workspace:*", "@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*", "@grafana/runtime": "workspace:*",
"@grafana/saga-icons": "workspace:*", "@grafana/saga-icons": "workspace:*",
"@grafana/scenes": "5.16.2", "@grafana/scenes": "5.18.3",
"@grafana/schema": "workspace:*", "@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*", "@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*", "@grafana/ui": "workspace:*",
"@hello-pangea/dnd": "16.6.0", "@hello-pangea/dnd": "16.6.0",
"@kusto/monaco-kusto": "^10.0.0", "@kusto/monaco-kusto": "^10.0.0",
"@leeoniya/ufuzzy": "1.0.14", "@leeoniya/ufuzzy": "1.0.14",
"@lezer/common": "1.2.1", "@lezer/common": "1.2.2",
"@lezer/highlight": "1.2.1", "@lezer/highlight": "1.2.1",
"@lezer/lr": "1.3.3", "@lezer/lr": "1.3.3",
"@locker/near-membrane-dom": "0.13.6", "@locker/near-membrane-dom": "0.13.6",
@@ -287,12 +287,12 @@
"@opentelemetry/exporter-collector": "0.25.0", "@opentelemetry/exporter-collector": "0.25.0",
"@opentelemetry/semantic-conventions": "1.27.0", "@opentelemetry/semantic-conventions": "1.27.0",
"@popperjs/core": "2.11.8", "@popperjs/core": "2.11.8",
"@react-aria/dialog": "3.5.17", "@react-aria/dialog": "3.5.18",
"@react-aria/focus": "3.18.2", "@react-aria/focus": "3.18.3",
"@react-aria/overlays": "3.23.2", "@react-aria/overlays": "3.23.3",
"@react-aria/utils": "3.25.2", "@react-aria/utils": "3.25.3",
"@react-awesome-query-builder/ui": "6.6.3", "@react-awesome-query-builder/ui": "6.6.3",
"@reduxjs/toolkit": "2.2.7", "@reduxjs/toolkit": "2.2.8",
"@testing-library/react-hooks": "^8.0.1", "@testing-library/react-hooks": "^8.0.1",
"@visx/event": "3.3.0", "@visx/event": "3.3.0",
"@visx/gradient": "3.3.0", "@visx/gradient": "3.3.0",
@@ -345,7 +345,7 @@
"ml-regression-polynomial": "^3.0.0", "ml-regression-polynomial": "^3.0.0",
"ml-regression-simple-linear": "^3.0.0", "ml-regression-simple-linear": "^3.0.0",
"moment": "2.30.1", "moment": "2.30.1",
"moment-timezone": "0.5.45", "moment-timezone": "0.5.46",
"monaco-editor": "0.34.1", "monaco-editor": "0.34.1",
"moveable": "0.53.0", "moveable": "0.53.0",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
@@ -354,7 +354,7 @@
"ol-ext": "4.0.23", "ol-ext": "4.0.23",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"prismjs": "1.29.0", "prismjs": "1.29.0",
"rc-slider": "11.1.6", "rc-slider": "11.1.7",
"rc-time-picker": "3.7.3", "rc-time-picker": "3.7.3",
"rc-tree": "5.9.0", "rc-tree": "5.9.0",
"re-resizable": "6.10.0", "re-resizable": "6.10.0",
+3 -3
View File
@@ -49,7 +49,7 @@
"marked": "12.0.2", "marked": "12.0.2",
"marked-mangle": "1.1.9", "marked-mangle": "1.1.9",
"moment": "2.30.1", "moment": "2.30.1",
"moment-timezone": "0.5.45", "moment-timezone": "0.5.46",
"ol": "7.4.0", "ol": "7.4.0",
"papaparse": "5.4.1", "papaparse": "5.4.1",
"react-use": "17.5.1", "react-use": "17.5.1",
@@ -65,8 +65,8 @@
"@rollup/plugin-node-resolve": "15.3.0", "@rollup/plugin-node-resolve": "15.3.0",
"@types/dompurify": "^3.0.0", "@types/dompurify": "^3.0.0",
"@types/history": "4.7.11", "@types/history": "4.7.11",
"@types/lodash": "4.17.9", "@types/lodash": "4.17.10",
"@types/node": "20.16.9", "@types/node": "20.16.11",
"@types/papaparse": "5.3.14", "@types/papaparse": "5.3.14",
"@types/react": "18.3.3", "@types/react": "18.3.3",
"@types/react-dom": "18.2.25", "@types/react-dom": "18.2.25",
@@ -34,6 +34,10 @@ export const limitTransformer: DataTransformerInfo<LimitTransformerOptions> = {
limit = options.limitField; limit = options.limitField;
} }
} }
// Prevent negative limit
if (limit < 0) {
limit = 0;
}
return data.map((frame) => { return data.map((frame) => {
if (frame.length > limit) { if (frame.length > limit) {
return { return {
@@ -158,6 +158,7 @@ export interface FeatureToggles {
newFolderPicker?: boolean; newFolderPicker?: boolean;
jitterAlertRulesWithinGroups?: boolean; jitterAlertRulesWithinGroups?: boolean;
onPremToCloudMigrations?: boolean; onPremToCloudMigrations?: boolean;
onPremToCloudMigrationsAlerts?: boolean;
alertingSaveStatePeriodic?: boolean; alertingSaveStatePeriodic?: boolean;
promQLScope?: boolean; promQLScope?: boolean;
sqlExpressions?: boolean; sqlExpressions?: boolean;
@@ -219,4 +220,5 @@ export interface FeatureToggles {
useSessionStorageForRedirection?: boolean; useSessionStorageForRedirection?: boolean;
rolePickerDrawer?: boolean; rolePickerDrawer?: boolean;
unifiedStorageSearch?: boolean; unifiedStorageSearch?: boolean;
pluginsSriChecks?: boolean;
} }
@@ -99,6 +99,7 @@ export interface PluginMeta<T extends KeyValue = {}> {
angularDetected?: boolean; angularDetected?: boolean;
loadingStrategy?: PluginLoadingStrategy; loadingStrategy?: PluginLoadingStrategy;
extensions?: PluginExtensions; extensions?: PluginExtensions;
moduleHash?: string;
} }
interface PluginDependencyInfo { interface PluginDependencyInfo {
+1 -1
View File
@@ -40,7 +40,7 @@
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-node-resolve": "15.3.0", "@rollup/plugin-node-resolve": "15.3.0",
"@types/node": "20.16.9", "@types/node": "20.16.11",
"esbuild": "0.24.0", "esbuild": "0.24.0",
"rimraf": "6.0.1", "rimraf": "6.0.1",
"rollup": "^4.22.4", "rollup": "^4.22.4",
+6 -6
View File
@@ -43,7 +43,7 @@
"not IE 11" "not IE 11"
], ],
"dependencies": { "dependencies": {
"@emotion/css": "11.13.0", "@emotion/css": "11.13.4",
"@grafana/data": "11.3.0-pre", "@grafana/data": "11.3.0-pre",
"@grafana/ui": "11.3.0-pre", "@grafana/ui": "11.3.0-pre",
"@leeoniya/ufuzzy": "1.0.14", "@leeoniya/ufuzzy": "1.0.14",
@@ -56,9 +56,9 @@
"tslib": "2.7.0" "tslib": "2.7.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.25.2", "@babel/core": "7.25.7",
"@babel/preset-env": "7.25.4", "@babel/preset-env": "7.25.7",
"@babel/preset-react": "7.24.7", "@babel/preset-react": "7.25.7",
"@grafana/tsconfig": "^2.0.0", "@grafana/tsconfig": "^2.0.0",
"@rollup/plugin-node-resolve": "15.3.0", "@rollup/plugin-node-resolve": "15.3.0",
"@testing-library/dom": "10.0.0", "@testing-library/dom": "10.0.0",
@@ -67,8 +67,8 @@
"@testing-library/user-event": "14.5.2", "@testing-library/user-event": "14.5.2",
"@types/d3": "^7", "@types/d3": "^7",
"@types/jest": "^29.5.4", "@types/jest": "^29.5.4",
"@types/lodash": "4.17.9", "@types/lodash": "4.17.10",
"@types/node": "20.16.9", "@types/node": "20.16.11",
"@types/react": "18.3.3", "@types/react": "18.3.3",
"@types/react-virtualized-auto-sizer": "1.0.4", "@types/react-virtualized-auto-sizer": "1.0.4",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
+2 -2
View File
@@ -34,7 +34,7 @@
"build": "yarn generate && rollup -c rollup.config.ts --configPlugin esbuild" "build": "yarn generate && rollup -c rollup.config.ts --configPlugin esbuild"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.25.2", "@babel/core": "7.25.7",
"@grafana/tsconfig": "^2.0.0", "@grafana/tsconfig": "^2.0.0",
"@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-typescript": "^12.1.0", "@rollup/plugin-typescript": "^12.1.0",
@@ -45,7 +45,7 @@
"@svgr/plugin-prettier": "^8.1.0", "@svgr/plugin-prettier": "^8.1.0",
"@svgr/plugin-svgo": "^8.1.0", "@svgr/plugin-svgo": "^8.1.0",
"@types/babel__core": "^7", "@types/babel__core": "^7",
"@types/node": "20.16.9", "@types/node": "20.16.11",
"@types/react": "18.3.3", "@types/react": "18.3.3",
"@types/react-dom": "18.2.25", "@types/react-dom": "18.2.25",
"esbuild": "0.24.0", "esbuild": "0.24.0",
@@ -17,10 +17,10 @@
"typecheck": "tsc --emitDeclarationOnly false --noEmit" "typecheck": "tsc --emitDeclarationOnly false --noEmit"
}, },
"dependencies": { "dependencies": {
"@emotion/css": "11.13.0", "@emotion/css": "11.13.4",
"@grafana/data": "11.3.0-pre", "@grafana/data": "11.3.0-pre",
"@grafana/e2e-selectors": "11.3.0-pre", "@grafana/e2e-selectors": "11.3.0-pre",
"@grafana/experimental": "1.8.0", "@grafana/experimental": "2.1.1",
"@grafana/runtime": "11.3.0-pre", "@grafana/runtime": "11.3.0-pre",
"@grafana/schema": "11.3.0-pre", "@grafana/schema": "11.3.0-pre",
"@grafana/ui": "11.3.0-pre", "@grafana/ui": "11.3.0-pre",
@@ -36,7 +36,7 @@
"@testing-library/react": "15.0.2", "@testing-library/react": "15.0.2",
"@testing-library/user-event": "14.5.2", "@testing-library/user-event": "14.5.2",
"@types/jest": "^29.5.4", "@types/jest": "^29.5.4",
"@types/node": "20.16.9", "@types/node": "20.16.11",
"@types/react": "18.3.3", "@types/react": "18.3.3",
"@types/systemjs": "6.15.1", "@types/systemjs": "6.15.1",
"@types/testing-library__jest-dom": "5.14.9", "@types/testing-library__jest-dom": "5.14.9",
+9 -9
View File
@@ -36,21 +36,21 @@
"postpack": "mv package.json.bak package.json" "postpack": "mv package.json.bak package.json"
}, },
"dependencies": { "dependencies": {
"@emotion/css": "11.13.0", "@emotion/css": "11.13.4",
"@floating-ui/react": "0.26.24", "@floating-ui/react": "0.26.24",
"@grafana/data": "11.3.0-pre", "@grafana/data": "11.3.0-pre",
"@grafana/experimental": "1.8.0", "@grafana/experimental": "2.1.1",
"@grafana/faro-web-sdk": "1.10.1", "@grafana/faro-web-sdk": "1.10.2",
"@grafana/runtime": "11.3.0-pre", "@grafana/runtime": "11.3.0-pre",
"@grafana/schema": "11.3.0-pre", "@grafana/schema": "11.3.0-pre",
"@grafana/ui": "11.3.0-pre", "@grafana/ui": "11.3.0-pre",
"@hello-pangea/dnd": "16.6.0", "@hello-pangea/dnd": "16.6.0",
"@leeoniya/ufuzzy": "1.0.14", "@leeoniya/ufuzzy": "1.0.14",
"@lezer/common": "1.2.1", "@lezer/common": "1.2.2",
"@lezer/highlight": "1.2.1", "@lezer/highlight": "1.2.1",
"@lezer/lr": "1.4.2", "@lezer/lr": "1.4.2",
"@prometheus-io/lezer-promql": "0.54.1", "@prometheus-io/lezer-promql": "0.54.1",
"@reduxjs/toolkit": "2.2.7", "@reduxjs/toolkit": "2.2.8",
"d3": "7.9.0", "d3": "7.9.0",
"date-fns": "3.6.0", "date-fns": "3.6.0",
"debounce-promise": "3.1.2", "debounce-promise": "3.1.2",
@@ -60,7 +60,7 @@
"marked": "12.0.2", "marked": "12.0.2",
"marked-mangle": "1.1.9", "marked-mangle": "1.1.9",
"moment": "2.30.1", "moment": "2.30.1",
"moment-timezone": "0.5.45", "moment-timezone": "0.5.46",
"monaco-promql": "1.7.4", "monaco-promql": "1.7.4",
"pluralize": "8.0.0", "pluralize": "8.0.0",
"prismjs": "1.29.0", "prismjs": "1.29.0",
@@ -91,8 +91,8 @@
"@types/eslint": "8.56.10", "@types/eslint": "8.56.10",
"@types/jest": "29.5.13", "@types/jest": "29.5.13",
"@types/jquery": "3.5.31", "@types/jquery": "3.5.31",
"@types/lodash": "4.17.9", "@types/lodash": "4.17.10",
"@types/node": "20.16.9", "@types/node": "20.16.11",
"@types/pluralize": "^0.0.33", "@types/pluralize": "^0.0.33",
"@types/prismjs": "1.26.4", "@types/prismjs": "1.26.4",
"@types/react": "18.3.3", "@types/react": "18.3.3",
@@ -114,7 +114,7 @@
"eslint-plugin-jsdoc": "48.11.0", "eslint-plugin-jsdoc": "48.11.0",
"eslint-plugin-jsx-a11y": "6.10.0", "eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-lodash": "7.4.0", "eslint-plugin-lodash": "7.4.0",
"eslint-plugin-react": "7.37.0", "eslint-plugin-react": "7.37.1",
"eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-hooks": "4.6.0",
"eslint-webpack-plugin": "4.2.0", "eslint-webpack-plugin": "4.2.0",
"fork-ts-checker-webpack-plugin": "9.0.2", "fork-ts-checker-webpack-plugin": "9.0.2",
+1 -1
View File
@@ -57,7 +57,7 @@
"@types/angular": "1.8.9", "@types/angular": "1.8.9",
"@types/history": "4.7.11", "@types/history": "4.7.11",
"@types/jest": "29.5.13", "@types/jest": "29.5.13",
"@types/lodash": "4.17.9", "@types/lodash": "4.17.10",
"@types/react": "18.3.3", "@types/react": "18.3.3",
"@types/react-dom": "18.2.25", "@types/react-dom": "18.2.25",
"@types/systemjs": "6.15.1", "@types/systemjs": "6.15.1",
+1
View File
@@ -46,6 +46,7 @@ export type AppPluginConfig = {
loadingStrategy: PluginLoadingStrategy; loadingStrategy: PluginLoadingStrategy;
dependencies: PluginDependencies; dependencies: PluginDependencies;
extensions: PluginExtensions; extensions: PluginExtensions;
moduleHash?: string;
}; };
export type PreinstalledPlugin = { export type PreinstalledPlugin = {
+4 -4
View File
@@ -14,10 +14,10 @@
"typecheck": "tsc --emitDeclarationOnly false --noEmit" "typecheck": "tsc --emitDeclarationOnly false --noEmit"
}, },
"dependencies": { "dependencies": {
"@emotion/css": "11.13.0", "@emotion/css": "11.13.4",
"@grafana/data": "11.3.0-pre", "@grafana/data": "11.3.0-pre",
"@grafana/e2e-selectors": "11.3.0-pre", "@grafana/e2e-selectors": "11.3.0-pre",
"@grafana/experimental": "1.8.0", "@grafana/experimental": "2.1.1",
"@grafana/runtime": "11.3.0-pre", "@grafana/runtime": "11.3.0-pre",
"@grafana/ui": "11.3.0-pre", "@grafana/ui": "11.3.0-pre",
"@react-awesome-query-builder/ui": "6.6.3", "@react-awesome-query-builder/ui": "6.6.3",
@@ -41,8 +41,8 @@
"@testing-library/react-hooks": "^8.0.1", "@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "14.5.2", "@testing-library/user-event": "14.5.2",
"@types/jest": "^29.5.4", "@types/jest": "^29.5.4",
"@types/lodash": "4.17.9", "@types/lodash": "4.17.10",
"@types/node": "20.16.9", "@types/node": "20.16.11",
"@types/react": "18.3.3", "@types/react": "18.3.3",
"@types/react-dom": "18.2.25", "@types/react-dom": "18.2.25",
"@types/react-virtualized-auto-sizer": "1.0.4", "@types/react-virtualized-auto-sizer": "1.0.4",
+10 -10
View File
@@ -47,7 +47,7 @@
"not IE 11" "not IE 11"
], ],
"dependencies": { "dependencies": {
"@emotion/css": "11.13.0", "@emotion/css": "11.13.4",
"@emotion/react": "11.13.3", "@emotion/react": "11.13.3",
"@emotion/serialize": "1.3.2", "@emotion/serialize": "1.3.2",
"@floating-ui/react": "0.26.24", "@floating-ui/react": "0.26.24",
@@ -59,13 +59,13 @@
"@leeoniya/ufuzzy": "1.0.14", "@leeoniya/ufuzzy": "1.0.14",
"@monaco-editor/react": "4.6.0", "@monaco-editor/react": "4.6.0",
"@popperjs/core": "2.11.8", "@popperjs/core": "2.11.8",
"@react-aria/dialog": "3.5.17", "@react-aria/dialog": "3.5.18",
"@react-aria/focus": "3.18.2", "@react-aria/focus": "3.18.3",
"@react-aria/overlays": "3.23.2", "@react-aria/overlays": "3.23.3",
"@react-aria/utils": "3.25.2", "@react-aria/utils": "3.25.3",
"@tanstack/react-virtual": "^3.5.1", "@tanstack/react-virtual": "^3.5.1",
"@types/jquery": "3.5.31", "@types/jquery": "3.5.31",
"@types/lodash": "4.17.9", "@types/lodash": "4.17.10",
"@types/react-table": "7.7.20", "@types/react-table": "7.7.20",
"ansicolor": "1.1.100", "ansicolor": "1.1.100",
"calculate-size": "1.1.1", "calculate-size": "1.1.1",
@@ -87,13 +87,13 @@
"prismjs": "1.29.0", "prismjs": "1.29.0",
"rc-cascader": "3.28.1", "rc-cascader": "3.28.1",
"rc-drawer": "7.2.0", "rc-drawer": "7.2.0",
"rc-slider": "11.1.6", "rc-slider": "11.1.7",
"rc-time-picker": "^3.7.3", "rc-time-picker": "^3.7.3",
"rc-tooltip": "6.2.1", "rc-tooltip": "6.2.1",
"react-calendar": "5.0.0", "react-calendar": "5.0.0",
"react-colorful": "5.6.1", "react-colorful": "5.6.1",
"react-custom-scrollbars-2": "4.5.0", "react-custom-scrollbars-2": "4.5.0",
"react-dropzone": "14.2.3", "react-dropzone": "14.2.9",
"react-highlight-words": "0.20.0", "react-highlight-words": "0.20.0",
"react-hook-form": "^7.49.2", "react-hook-form": "^7.49.2",
"react-i18next": "^14.0.0", "react-i18next": "^14.0.0",
@@ -115,7 +115,7 @@
"uuid": "9.0.1" "uuid": "9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.25.2", "@babel/core": "7.25.7",
"@faker-js/faker": "^9.0.0", "@faker-js/faker": "^9.0.0",
"@grafana/tsconfig": "^2.0.0", "@grafana/tsconfig": "^2.0.0",
"@rollup/plugin-node-resolve": "15.3.0", "@rollup/plugin-node-resolve": "15.3.0",
@@ -146,7 +146,7 @@
"@types/is-hotkey": "0.1.10", "@types/is-hotkey": "0.1.10",
"@types/jest": "29.5.13", "@types/jest": "29.5.13",
"@types/mock-raf": "1.0.6", "@types/mock-raf": "1.0.6",
"@types/node": "20.16.9", "@types/node": "20.16.11",
"@types/prismjs": "1.26.4", "@types/prismjs": "1.26.4",
"@types/react": "18.3.3", "@types/react": "18.3.3",
"@types/react-color": "3.0.12", "@types/react-color": "3.0.12",
@@ -152,7 +152,13 @@ export const MenuItem = React.memo(
className={itemStyle} className={itemStyle}
rel={target === '_blank' ? 'noopener noreferrer' : undefined} rel={target === '_blank' ? 'noopener noreferrer' : undefined}
href={url} href={url}
onClick={onClick} onClick={(event) => {
if (hasSubMenu && !isSubMenuOpen) {
event.preventDefault();
event.stopPropagation();
}
onClick?.(event);
}}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave} onMouseLeave={onMouseLeave}
onKeyDown={handleKeys} onKeyDown={handleKeys}
@@ -40,6 +40,7 @@ export const TableCell = ({
} }
if (cellProps.style) { if (cellProps.style) {
cellProps.style.wordBreak = 'break-word';
cellProps.style.minWidth = cellProps.style.width; cellProps.style.minWidth = cellProps.style.width;
const justifyContent = (cell.column as any).justifyContent; const justifyContent = (cell.column as any).justifyContent;
@@ -180,6 +180,7 @@ export class VizRepeater<V, D = {}> extends PureComponent<PropsWithDefaults<V, D
const defaultVizHeight = (height + itemSpacing) / values.length - itemSpacing; const defaultVizHeight = (height + itemSpacing) / values.length - itemSpacing;
repeaterStyle.flexDirection = 'column'; repeaterStyle.flexDirection = 'column';
repeaterStyle.height = `${height}px`; repeaterStyle.height = `${height}px`;
repeaterStyle.overflowX = 'hidden';
itemStyles.marginBottom = `${itemSpacing}px`; itemStyles.marginBottom = `${itemSpacing}px`;
vizWidth = width; vizWidth = width;
vizHeight = clamp(defaultVizHeight, minVizHeight ?? 0, maxVizHeight ?? defaultVizHeight); vizHeight = clamp(defaultVizHeight, minVizHeight ?? 0, maxVizHeight ?? defaultVizHeight);
@@ -187,6 +188,7 @@ export class VizRepeater<V, D = {}> extends PureComponent<PropsWithDefaults<V, D
case VizOrientation.Vertical: case VizOrientation.Vertical:
repeaterStyle.flexDirection = 'row'; repeaterStyle.flexDirection = 'row';
repeaterStyle.justifyContent = 'space-between'; repeaterStyle.justifyContent = 'space-between';
repeaterStyle.overflowY = 'hidden';
itemStyles.marginRight = `${itemSpacing}px`; itemStyles.marginRight = `${itemSpacing}px`;
vizHeight = height; vizHeight = height;
vizWidth = Math.max(width / values.length - itemSpacing + itemSpacing / values.length, minVizWidth ?? 0); vizWidth = Math.max(width / values.length - itemSpacing + itemSpacing / values.length, minVizWidth ?? 0);
+3 -3
View File
@@ -656,15 +656,15 @@ func (hs *HTTPServer) declareFixedRoles() error {
// Metadata helpers // Metadata helpers
// getAccessControlMetadata returns the accesscontrol metadata associated with a given resource // getAccessControlMetadata returns the accesscontrol metadata associated with a given resource
func (hs *HTTPServer) getAccessControlMetadata(c *contextmodel.ReqContext, func getAccessControlMetadata(c *contextmodel.ReqContext,
prefix string, resourceID string) ac.Metadata { prefix string, resourceID string) ac.Metadata {
ids := map[string]bool{resourceID: true} ids := map[string]bool{resourceID: true}
return hs.getMultiAccessControlMetadata(c, prefix, ids)[resourceID] return getMultiAccessControlMetadata(c, prefix, ids)[resourceID]
} }
// getMultiAccessControlMetadata returns the accesscontrol metadata associated with a given set of resources // getMultiAccessControlMetadata returns the accesscontrol metadata associated with a given set of resources
// Context must contain permissions in the given org (see LoadPermissionsMiddleware or AuthorizeInOrgMiddleware) // Context must contain permissions in the given org (see LoadPermissionsMiddleware or AuthorizeInOrgMiddleware)
func (hs *HTTPServer) getMultiAccessControlMetadata(c *contextmodel.ReqContext, func getMultiAccessControlMetadata(c *contextmodel.ReqContext,
prefix string, resourceIDs map[string]bool) map[string]ac.Metadata { prefix string, resourceIDs map[string]bool) map[string]ac.Metadata {
if !c.QueryBool("accesscontrol") { if !c.QueryBool("accesscontrol") {
return map[string]ac.Metadata{} return map[string]ac.Metadata{}
+4
View File
@@ -306,6 +306,10 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/search-v2", hs.SearchV2HTTPService.RegisterHTTPRoutes) apiRoute.Group("/search-v2", hs.SearchV2HTTPService.RegisterHTTPRoutes)
} }
if hs.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) {
apiRoute.Group("/unified-search", hs.UnifiedSearchHTTPService.RegisterHTTPRoutes)
}
// current org // current org
apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
userIDScope := ac.Scope("users", "id", ac.Parameter(":userId")) userIDScope := ac.Scope("users", "id", ac.Parameter(":userId"))
+1 -1
View File
@@ -56,7 +56,7 @@ func (hs *HTTPServer) GetAPIKeys(c *contextmodel.ReqContext) response.Response {
} }
} }
metadata := hs.getMultiAccessControlMetadata(c, "apikeys:id", ids) metadata := getMultiAccessControlMetadata(c, "apikeys:id", ids)
if len(metadata) > 0 { if len(metadata) > 0 {
for _, key := range result { for _, key := range result {
key.AccessControl = metadata[strconv.FormatInt(key.ID, 10)] key.AccessControl = metadata[strconv.FormatInt(key.ID, 10)]
+2 -2
View File
@@ -136,7 +136,7 @@ func (hs *HTTPServer) GetDataSourceById(c *contextmodel.ReqContext) response.Res
dto := hs.convertModelToDtos(c.Req.Context(), dataSource) dto := hs.convertModelToDtos(c.Req.Context(), dataSource)
// Add accesscontrol metadata // Add accesscontrol metadata
dto.AccessControl = hs.getAccessControlMetadata(c, datasources.ScopePrefix, dto.UID) dto.AccessControl = getAccessControlMetadata(c, datasources.ScopePrefix, dto.UID)
return response.JSON(http.StatusOK, &dto) return response.JSON(http.StatusOK, &dto)
} }
@@ -222,7 +222,7 @@ func (hs *HTTPServer) GetDataSourceByUID(c *contextmodel.ReqContext) response.Re
dto := hs.convertModelToDtos(c.Req.Context(), ds) dto := hs.convertModelToDtos(c.Req.Context(), ds)
// Add accesscontrol metadata // Add accesscontrol metadata
dto.AccessControl = hs.getAccessControlMetadata(c, datasources.ScopePrefix, dto.UID) dto.AccessControl = getAccessControlMetadata(c, datasources.ScopePrefix, dto.UID)
return response.JSON(http.StatusOK, &dto) return response.JSON(http.StatusOK, &dto)
} }
+1
View File
@@ -30,6 +30,7 @@ type PluginSetting struct {
SignatureOrg string `json:"signatureOrg"` SignatureOrg string `json:"signatureOrg"`
AngularDetected bool `json:"angularDetected"` AngularDetected bool `json:"angularDetected"`
LoadingStrategy plugins.LoadingStrategy `json:"loadingStrategy"` LoadingStrategy plugins.LoadingStrategy `json:"loadingStrategy"`
ModuleHash string `json:"moduleHash,omitempty"`
} }
type PluginListItem struct { type PluginListItem struct {
+211 -15
View File
@@ -1,12 +1,16 @@
package api package api
import ( import (
"context"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
k8sErrors "k8s.io/apimachinery/pkg/api/errors" k8sErrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic"
@@ -28,6 +32,7 @@ import (
"github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/errhttp" "github.com/grafana/grafana/pkg/util/errhttp"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
@@ -448,7 +453,7 @@ func (hs *HTTPServer) getFolderACMetadata(c *contextmodel.ReqContext, f *folder.
folderIDs[p.UID] = true folderIDs[p.UID] = true
} }
allMetadata := hs.getMultiAccessControlMetadata(c, dashboards.ScopeFoldersPrefix, folderIDs) allMetadata := getMultiAccessControlMetadata(c, dashboards.ScopeFoldersPrefix, folderIDs)
metadata := map[string]bool{} metadata := map[string]bool{}
// Flatten metadata - if any parent has a permission, the child folder inherits it // Flatten metadata - if any parent has a permission, the child folder inherits it
for _, md := range allMetadata { for _, md := range allMetadata {
@@ -629,6 +634,9 @@ type folderK8sHandler struct {
clientConfigProvider grafanaapiserver.DirectRestConfigProvider clientConfigProvider grafanaapiserver.DirectRestConfigProvider
// #TODO check if it makes more sense to move this to FolderAPIBuilder // #TODO check if it makes more sense to move this to FolderAPIBuilder
accesscontrolService accesscontrol.Service accesscontrolService accesscontrol.Service
userService user.Service
// #TODO remove after we handle the nested folder case
folderService folder.Service
} }
//----------------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------------
@@ -641,6 +649,8 @@ func newFolderK8sHandler(hs *HTTPServer) *folderK8sHandler {
namespacer: request.GetNamespaceMapper(hs.Cfg), namespacer: request.GetNamespaceMapper(hs.Cfg),
clientConfigProvider: hs.clientConfigProvider, clientConfigProvider: hs.clientConfigProvider,
accesscontrolService: hs.accesscontrolService, accesscontrolService: hs.accesscontrolService,
userService: hs.userService,
folderService: hs.folderService,
} }
} }
@@ -693,12 +703,13 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) {
} }
fk8s.accesscontrolService.ClearUserPermissionCache(c.SignedInUser) fk8s.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID())
if err != nil { if err != nil {
fk8s.writeError(c, err) fk8s.writeError(c, err)
return return
} }
c.JSON(http.StatusOK, f)
c.JSON(http.StatusOK, folderDTO)
} }
// func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) { // func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) {
@@ -713,13 +724,13 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) {
// return // return
// } // }
// f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) // folderDTO, err := fk8s.newToFolderDto(c, *out)
// if err != nil { // if err != nil {
// fk8s.writeError(c, err) // fk8s.writeError(c, err)
// return // return
// } // }
// c.JSON(http.StatusOK, f) // c.JSON(http.StatusOK, folderDTO)
// } // }
// func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) { // func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) {
@@ -755,13 +766,13 @@ func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) {
// return // return
// } // }
// f, err := internalfolders.UnstructuredToLegacyFolderDTO(*out) // folderDTO, err := fk8s.newToFolderDto(c, *out)
// if err != nil { // if err != nil {
// fk8s.writeError(c, err) // fk8s.writeError(c, err)
// return // return
// } // }
// c.JSON(http.StatusOK, f) // c.JSON(http.StatusOK, folderDTO)
// } // }
//----------------------------------------------------------------------------------------- //-----------------------------------------------------------------------------------------
@@ -786,3 +797,188 @@ func (fk8s *folderK8sHandler) writeError(c *contextmodel.ReqContext, err error)
} }
errhttp.Write(c.Req.Context(), err, c.Resp) errhttp.Write(c.Req.Context(), err, c.Resp)
} }
func (fk8s *folderK8sHandler) newToFolderDto(c *contextmodel.ReqContext, item unstructured.Unstructured, orgID int64) (dtos.Folder, error) {
// #TODO revisit how/where we get orgID
ctx := c.Req.Context()
f := internalfolders.UnstructuredToLegacyFolder(item, orgID)
fDTO, err := internalfolders.UnstructuredToLegacyFolderDTO(item)
if err != nil {
return dtos.Folder{}, err
}
toID := func(rawIdentifier string) (int64, error) {
parts := strings.Split(rawIdentifier, ":")
if len(parts) < 2 {
return 0, fmt.Errorf("invalid user identifier")
}
userID, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("faild to parse user identifier")
}
return userID, nil
}
toDTO := func(fold *folder.Folder, checkCanView bool) (dtos.Folder, error) {
g, err := guardian.NewByFolder(c.Req.Context(), fold, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return dtos.Folder{}, err
}
canEdit, _ := g.CanEdit()
canSave, _ := g.CanSave()
canAdmin, _ := g.CanAdmin()
canDelete, _ := g.CanDelete()
// Finding creator and last updater of the folder
updater, creator := anonString, anonString
// #TODO refactor the various conversions of the folder so that we either set created by in folder.Folder or
// we convert from unstructured to folder DTO without an intermediate conversion to folder.Folder
if len(fDTO.CreatedBy) > 0 {
id, err := toID(fDTO.CreatedBy)
if err != nil {
return dtos.Folder{}, err
}
creator = fk8s.getUserLogin(ctx, id)
}
if len(fDTO.UpdatedBy) > 0 {
id, err := toID(fDTO.UpdatedBy)
if err != nil {
return dtos.Folder{}, err
}
updater = fk8s.getUserLogin(ctx, id)
}
acMetadata, _ := fk8s.getFolderACMetadata(c, fold)
if checkCanView {
canView, _ := g.CanView()
if !canView {
return dtos.Folder{
UID: REDACTED,
Title: REDACTED,
}, nil
}
}
metrics.MFolderIDsAPICount.WithLabelValues(metrics.NewToFolderDTO).Inc()
fDTO.CanSave = canSave
fDTO.CanEdit = canEdit
fDTO.CanAdmin = canAdmin
fDTO.CanDelete = canDelete
fDTO.CreatedBy = creator
fDTO.UpdatedBy = updater
fDTO.AccessControl = acMetadata
fDTO.OrgID = f.OrgID
// #TODO version doesn't seem to be used--confirm or set it properly
fDTO.Version = 1
return *fDTO, nil
}
// no need to check view permission for the starting folder since it's already checked by the callers
folderDTO, err := toDTO(f, false)
if err != nil {
return dtos.Folder{}, err
}
parents := []*folder.Folder{}
if folderDTO.ParentUID != "" {
parents, err = fk8s.folderService.GetParents(
c.Req.Context(),
folder.GetParentsQuery{
UID: folderDTO.UID,
OrgID: folderDTO.OrgID,
})
if err != nil {
return dtos.Folder{}, err
}
}
// #TODO refactor so that we have just one function for converting to folder DTO
toParentDTO := func(fold *folder.Folder, checkCanView bool) (dtos.Folder, error) {
g, err := guardian.NewByFolder(c.Req.Context(), fold, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return dtos.Folder{}, err
}
if checkCanView {
canView, _ := g.CanView()
if !canView {
return dtos.Folder{
UID: REDACTED,
Title: REDACTED,
}, nil
}
}
metrics.MFolderIDsAPICount.WithLabelValues(metrics.NewToFolderDTO).Inc()
return dtos.Folder{
UID: fold.UID,
Title: fold.Title,
URL: fold.URL,
}, nil
}
folderDTO.Parents = make([]dtos.Folder, 0, len(parents))
for _, f := range parents {
DTO, err := toParentDTO(f, true)
if err != nil {
// #TODO add logging
// fk8s.log.Error("failed to convert folder to DTO", "folder", f.UID, "org", f.OrgID, "error", err)
continue
}
folderDTO.Parents = append(folderDTO.Parents, DTO)
}
return folderDTO, nil
}
func (fk8s *folderK8sHandler) getUserLogin(ctx context.Context, userID int64) string {
ctx, span := tracer.Start(ctx, "api.getUserLogin")
defer span.End()
query := user.GetUserByIDQuery{ID: userID}
user, err := fk8s.userService.GetByID(ctx, &query)
if err != nil {
return anonString
}
return user.Login
}
func (fk8s *folderK8sHandler) getFolderACMetadata(c *contextmodel.ReqContext, f *folder.Folder) (accesscontrol.Metadata, error) {
if !c.QueryBool("accesscontrol") {
return nil, nil
}
var err error
parents := []*folder.Folder{}
if f.ParentUID != "" {
parents, err = fk8s.folderService.GetParents(
c.Req.Context(),
folder.GetParentsQuery{
UID: f.UID,
OrgID: c.SignedInUser.GetOrgID(),
})
if err != nil {
return nil, err
}
}
folderIDs := map[string]bool{f.UID: true}
for _, p := range parents {
folderIDs[p.UID] = true
}
allMetadata := getMultiAccessControlMetadata(c, dashboards.ScopeFoldersPrefix, folderIDs)
metadata := map[string]bool{}
// Flatten metadata - if any parent has a permission, the child folder inherits it
for _, md := range allMetadata {
for action := range md {
metadata[action] = true
}
}
return metadata, nil
}
+105
View File
@@ -5,17 +5,20 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"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"
clientrest "k8s.io/client-go/rest"
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest" "github.com/grafana/grafana/pkg/services/accesscontrol/actest"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"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/services/folder" "github.com/grafana/grafana/pkg/services/folder"
@@ -523,3 +526,105 @@ func TestFolderGetAPIEndpoint(t *testing.T) {
}) })
} }
} }
type mockClientConfigProvider struct {
host string
}
func (m mockClientConfigProvider) GetDirectRestConfig(c *contextmodel.ReqContext) *clientrest.Config {
return &clientrest.Config{
Host: m.host,
}
}
func (m mockClientConfigProvider) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {}
func TestHTTPServer_FolderMetadataK8s(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//nolint:errcheck
fmt.Fprintln(w,
`{
"kind": "Folder",
"apiVersion": "folder.grafana.app/v0alpha1",
"metadata": {
"name": "ady4yobv315a8e",
"namespace": "default",
"uid": "28f306ee-ada1-40f4-8011-b2d1df462aad",
"creationTimestamp": "2024-09-17T04:16:35Z",
"annotations": {
"grafana.app/createdBy": "user:fdxsqt7t5ryf4a",
"grafana.app/originName": "SQL",
"grafana.app/originPath": "3"
}
},
"spec": {
"title": "Example folder 226"
}
}`)
}))
defer ts.Close()
mockClientConfigProvider := mockClientConfigProvider{
host: ts.URL,
}
setUpRBACGuardian(t)
folderService := &foldertest.FakeService{}
features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagKubernetesFolders)
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
hs.folderService = folderService
hs.QuotaService = quotatest.New(false, nil)
hs.SearchService = &mockSearchService{
ExpectedResult: model.HitList{},
}
hs.Features = features
hs.clientConfigProvider = mockClientConfigProvider
})
t.Run("Should attach access control metadata to folder response", func(t *testing.T) {
folderService.ExpectedFolder = &folder.Folder{UID: "ady4yobv315a8e"}
req := server.NewGetRequest("/api/folders/ady4yobv315a8e?accesscontrol=true")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{
1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("ady4yobv315a8e")},
}),
}})
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 not attach access control metadata to folder response", func(t *testing.T) {
folderService.ExpectedFolder = &folder.Folder{UID: "ady4yobv315a8e"}
req := server.NewGetRequest("/api/folders/ady4yobv315a8e")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{
1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("ady4yobv315a8e")},
}),
}})
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])
})
}
+6 -2
View File
@@ -145,6 +145,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
AliasIDs: panel.AliasIDs, AliasIDs: panel.AliasIDs,
Info: panel.Info, Info: panel.Info,
Module: panel.Module, Module: panel.Module,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), panel),
BaseURL: panel.BaseURL, BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery, SkipDataQuery: panel.SkipDataQuery,
HideFromList: panel.HideFromList, HideFromList: panel.HideFromList,
@@ -453,6 +454,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
JSONData: plugin.JSONData, JSONData: plugin.JSONData,
Signature: plugin.Signature, Signature: plugin.Signature,
Module: plugin.Module, Module: plugin.Module,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
BaseURL: plugin.BaseURL, BaseURL: plugin.BaseURL,
Angular: plugin.Angular, Angular: plugin.Angular,
MultiValueFilterOperators: plugin.MultiValueFilterOperators, MultiValueFilterOperators: plugin.MultiValueFilterOperators,
@@ -538,8 +540,9 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
JSONData: ds.JSONData, JSONData: ds.JSONData,
Signature: ds.Signature, Signature: ds.Signature,
Module: ds.Module, Module: ds.Module,
BaseURL: ds.BaseURL, // ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), ds),
Angular: ds.Angular, BaseURL: ds.BaseURL,
Angular: ds.Angular,
}, },
} }
if ds.Name == grafanads.DatasourceName { if ds.Name == grafanads.DatasourceName {
@@ -563,6 +566,7 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin), LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin),
Extensions: plugin.Extensions, Extensions: plugin.Extensions,
Dependencies: plugin.Dependencies, Dependencies: plugin.Dependencies,
ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin),
} }
if settings.Enabled { if settings.Enabled {
+31 -23
View File
@@ -18,6 +18,8 @@ import (
"github.com/grafana/grafana/pkg/login/social/socialimpl" "github.com/grafana/grafana/pkg/login/social/socialimpl"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/pluginscdn"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@@ -51,10 +53,11 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
}) })
} }
pluginsCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{ pluginsCfg := &config.PluginManagementCfg{
PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate, PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate,
PluginSettings: cfg.PluginSettings, PluginSettings: cfg.PluginSettings,
}) }
pluginsCDN := pluginscdn.ProvideService(pluginsCfg)
var pluginStore = pstore var pluginStore = pstore
if pluginStore == nil { if pluginStore == nil {
@@ -68,7 +71,8 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
var pluginsAssets = passets var pluginsAssets = passets
if pluginsAssets == nil { if pluginsAssets == nil {
pluginsAssets = pluginassets.ProvideService(cfg, pluginsCDN) sig := signature.ProvideService(pluginsCfg, statickey.New())
pluginsAssets = pluginassets.ProvideService(pluginsCfg, pluginsCDN, sig, pluginStore)
} }
hs := &HTTPServer{ hs := &HTTPServer{
@@ -240,6 +244,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
PluginList: []pluginstore.Plugin{ PluginList: []pluginstore.Plugin{
{ {
Module: fmt.Sprintf("/%s/module.js", "test-app"), Module: fmt.Sprintf("/%s/module.js", "test-app"),
// ModuleHash: "sha256-test",
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-app", ID: "test-app",
Info: plugins.Info{Version: "0.5.0"}, Info: plugins.Info{Version: "0.5.0"},
@@ -255,9 +260,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", false), Plugins: newAppSettings("test-app", false),
} }
}, },
pluginAssets: func() *pluginassets.Service { pluginAssets: newPluginAssets(),
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
expected: settings{ expected: settings{
Apps: map[string]*plugins.AppDTO{ Apps: map[string]*plugins.AppDTO{
"test-app": { "test-app": {
@@ -266,6 +269,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Path: "/test-app/module.js", Path: "/test-app/module.js",
Version: "0.5.0", Version: "0.5.0",
LoadingStrategy: plugins.LoadingStrategyScript, LoadingStrategy: plugins.LoadingStrategyScript,
// ModuleHash: "sha256-test",
}, },
}, },
}, },
@@ -277,6 +281,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
PluginList: []pluginstore.Plugin{ PluginList: []pluginstore.Plugin{
{ {
Module: fmt.Sprintf("/%s/module.js", "test-app"), Module: fmt.Sprintf("/%s/module.js", "test-app"),
// ModuleHash: "sha256-test",
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-app", ID: "test-app",
Info: plugins.Info{Version: "0.5.0"}, Info: plugins.Info{Version: "0.5.0"},
@@ -292,9 +297,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true), Plugins: newAppSettings("test-app", true),
} }
}, },
pluginAssets: func() *pluginassets.Service { pluginAssets: newPluginAssets(),
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
expected: settings{ expected: settings{
Apps: map[string]*plugins.AppDTO{ Apps: map[string]*plugins.AppDTO{
"test-app": { "test-app": {
@@ -303,6 +306,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Path: "/test-app/module.js", Path: "/test-app/module.js",
Version: "0.5.0", Version: "0.5.0",
LoadingStrategy: plugins.LoadingStrategyScript, LoadingStrategy: plugins.LoadingStrategyScript,
// ModuleHash: "sha256-test",
}, },
}, },
}, },
@@ -330,9 +334,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true), Plugins: newAppSettings("test-app", true),
} }
}, },
pluginAssets: func() *pluginassets.Service { pluginAssets: newPluginAssets(),
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
expected: settings{ expected: settings{
Apps: map[string]*plugins.AppDTO{ Apps: map[string]*plugins.AppDTO{
"test-app": { "test-app": {
@@ -368,15 +370,13 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true), Plugins: newAppSettings("test-app", true),
} }
}, },
pluginAssets: func() *pluginassets.Service { pluginAssets: newPluginAssetsWithConfig(&config.PluginManagementCfg{
return pluginassets.ProvideService(&setting.Cfg{ PluginSettings: map[string]map[string]string{
PluginSettings: map[string]map[string]string{ "test-app": {
"test-app": { pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled,
pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled,
},
}, },
}, pluginscdn.ProvideService(&config.PluginManagementCfg{})) },
}, }),
expected: settings{ expected: settings{
Apps: map[string]*plugins.AppDTO{ Apps: map[string]*plugins.AppDTO{
"test-app": { "test-app": {
@@ -412,9 +412,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true), Plugins: newAppSettings("test-app", true),
} }
}, },
pluginAssets: func() *pluginassets.Service { pluginAssets: newPluginAssets(),
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
expected: settings{ expected: settings{
Apps: map[string]*plugins.AppDTO{ Apps: map[string]*plugins.AppDTO{
"test-app": { "test-app": {
@@ -456,3 +454,13 @@ func newAppSettings(id string, enabled bool) map[string]*pluginsettings.DTO {
}, },
} }
} }
func newPluginAssets() func() *pluginassets.Service {
return newPluginAssetsWithConfig(&config.PluginManagementCfg{})
}
func newPluginAssetsWithConfig(pCfg *config.PluginManagementCfg) func() *pluginassets.Service {
return func() *pluginassets.Service {
return pluginassets.ProvideService(pCfg, pluginscdn.ProvideService(pCfg), signature.ProvideService(pCfg, statickey.New()), &pluginstore.FakePluginStore{})
}
}
+4 -1
View File
@@ -104,6 +104,7 @@ import (
"github.com/grafana/grafana/pkg/services/tag" "github.com/grafana/grafana/pkg/services/tag"
"github.com/grafana/grafana/pkg/services/team" "github.com/grafana/grafana/pkg/services/team"
tempUser "github.com/grafana/grafana/pkg/services/temp_user" tempUser "github.com/grafana/grafana/pkg/services/temp_user"
"github.com/grafana/grafana/pkg/services/unifiedSearch"
"github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/services/updatechecker"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/validations" "github.com/grafana/grafana/pkg/services/validations"
@@ -156,6 +157,7 @@ type HTTPServer struct {
LivePushGateway *pushhttp.Gateway LivePushGateway *pushhttp.Gateway
StorageService store.StorageService StorageService store.StorageService
SearchV2HTTPService searchV2.SearchHTTPService SearchV2HTTPService searchV2.SearchHTTPService
UnifiedSearchHTTPService unifiedSearch.SearchHTTPService
ContextHandler *contexthandler.ContextHandler ContextHandler *contexthandler.ContextHandler
LoggerMiddleware loggermw.Logger LoggerMiddleware loggermw.Logger
SQLStore db.DB SQLStore db.DB
@@ -266,7 +268,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
publicDashboardsApi *publicdashboardsApi.Api, userService user.Service, tempUserService tempUser.Service, publicDashboardsApi *publicdashboardsApi.Api, userService user.Service, tempUserService tempUser.Service,
loginAttemptService loginAttempt.Service, orgService org.Service, teamService team.Service, loginAttemptService loginAttempt.Service, orgService org.Service, teamService team.Service,
accesscontrolService accesscontrol.Service, navTreeService navtree.Service, accesscontrolService accesscontrol.Service, navTreeService navtree.Service,
annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService, annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, unifiedSearchHTTPService unifiedSearch.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService,
statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, promGatherer prometheus.Gatherer, statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, promGatherer prometheus.Gatherer,
starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service, starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service,
userVerifier user.Verifier, userVerifier user.Verifier,
@@ -308,6 +310,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
AccessControl: accessControl, AccessControl: accessControl,
DataProxy: dataSourceProxy, DataProxy: dataSourceProxy,
SearchV2HTTPService: searchv2HTTPService, SearchV2HTTPService: searchv2HTTPService,
UnifiedSearchHTTPService: unifiedSearchHTTPService,
SearchService: searchService, SearchService: searchService,
Live: live, Live: live,
LivePushGateway: livePushGateway, LivePushGateway: livePushGateway,
+23 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/network" "github.com/grafana/grafana/pkg/infra/network"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/middleware/cookies"
"github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
@@ -181,6 +182,9 @@ func (hs *HTTPServer) tryAutoLogin(c *contextmodel.ReqContext) bool {
for providerName, provider := range oauthInfos { for providerName, provider := range oauthInfos {
if provider.AutoLogin || hs.Cfg.OAuthAutoLogin { if provider.AutoLogin || hs.Cfg.OAuthAutoLogin {
redirectUrl := hs.Cfg.AppSubURL + "/login/" + providerName redirectUrl := hs.Cfg.AppSubURL + "/login/" + providerName
if hs.Features.IsEnabledGlobally(featuremgmt.FlagUseSessionStorageForRedirection) {
redirectUrl += hs.getRedirectToForAutoLogin(c)
}
c.Logger.Info("OAuth auto login enabled. Redirecting to " + redirectUrl) c.Logger.Info("OAuth auto login enabled. Redirecting to " + redirectUrl)
c.Redirect(redirectUrl, 307) c.Redirect(redirectUrl, 307)
return true return true
@@ -189,6 +193,9 @@ func (hs *HTTPServer) tryAutoLogin(c *contextmodel.ReqContext) bool {
if samlAutoLogin { if samlAutoLogin {
redirectUrl := hs.Cfg.AppSubURL + "/login/saml" redirectUrl := hs.Cfg.AppSubURL + "/login/saml"
if hs.Features.IsEnabledGlobally(featuremgmt.FlagUseSessionStorageForRedirection) {
redirectUrl += hs.getRedirectToForAutoLogin(c)
}
c.Logger.Info("SAML auto login enabled. Redirecting to " + redirectUrl) c.Logger.Info("SAML auto login enabled. Redirecting to " + redirectUrl)
c.Redirect(redirectUrl, 307) c.Redirect(redirectUrl, 307)
return true return true
@@ -197,6 +204,21 @@ func (hs *HTTPServer) tryAutoLogin(c *contextmodel.ReqContext) bool {
return false return false
} }
func (hs *HTTPServer) getRedirectToForAutoLogin(c *contextmodel.ReqContext) string {
redirectTo := c.Req.FormValue("redirectTo")
if hs.Cfg.AppSubURL != "" && strings.HasPrefix(redirectTo, hs.Cfg.AppSubURL) {
redirectTo = strings.TrimPrefix(redirectTo, hs.Cfg.AppSubURL)
}
if redirectTo == "/" {
return ""
}
// remove any forceLogin=true params
redirectTo = middleware.RemoveForceLoginParams(redirectTo)
return "?redirectTo=" + url.QueryEscape(redirectTo)
}
func (hs *HTTPServer) LoginAPIPing(c *contextmodel.ReqContext) response.Response { func (hs *HTTPServer) LoginAPIPing(c *contextmodel.ReqContext) response.Response {
if c.IsSignedIn || c.IsAnonymous { if c.IsSignedIn || c.IsAnonymous {
return response.JSON(http.StatusOK, util.DynMap{"message": "Logged in"}) return response.JSON(http.StatusOK, util.DynMap{"message": "Logged in"})
@@ -233,7 +255,7 @@ func (hs *HTTPServer) loginUserWithUser(user *user.User, c *contextmodel.ReqCont
hs.log.Debug("Got IP address from client address", "addr", addr, "ip", ip) hs.log.Debug("Got IP address from client address", "addr", addr, "ip", ip)
ctx := context.WithValue(c.Req.Context(), loginservice.RequestURIKey{}, c.Req.RequestURI) ctx := context.WithValue(c.Req.Context(), loginservice.RequestURIKey{}, c.Req.RequestURI)
userToken, err := hs.AuthTokenService.CreateToken(ctx, user, ip, c.Req.UserAgent()) userToken, err := hs.AuthTokenService.CreateToken(ctx, &auth.CreateTokenCommand{User: user, ClientIP: ip, UserAgent: c.Req.UserAgent()})
if err != nil { if err != nil {
return fmt.Errorf("%v: %w", "failed to create auth token", err) return fmt.Errorf("%v: %w", "failed to create auth token", err)
} }
+5
View File
@@ -6,6 +6,7 @@ import (
"github.com/grafana/grafana/pkg/middleware/cookies" "github.com/grafana/grafana/pkg/middleware/cookies"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
) )
@@ -25,6 +26,7 @@ func (hs *HTTPServer) OAuthLogin(reqCtx *contextmodel.ReqContext) {
} }
code := reqCtx.Query("code") code := reqCtx.Query("code")
redirectTo := reqCtx.Query("redirectTo")
req := &authn.Request{HTTPRequest: reqCtx.Req} req := &authn.Request{HTTPRequest: reqCtx.Req}
if code == "" { if code == "" {
@@ -36,6 +38,9 @@ func (hs *HTTPServer) OAuthLogin(reqCtx *contextmodel.ReqContext) {
cookies.WriteCookie(reqCtx.Resp, OauthStateCookieName, redirect.Extra[authn.KeyOAuthState], hs.Cfg.OAuthCookieMaxAge, hs.CookieOptionsFromCfg) cookies.WriteCookie(reqCtx.Resp, OauthStateCookieName, redirect.Extra[authn.KeyOAuthState], hs.Cfg.OAuthCookieMaxAge, hs.CookieOptionsFromCfg)
if hs.Features.IsEnabledGlobally(featuremgmt.FlagUseSessionStorageForRedirection) {
cookies.WriteCookie(reqCtx.Resp, "redirectTo", redirectTo, hs.Cfg.OAuthCookieMaxAge, hs.CookieOptionsFromCfg)
}
if pkce := redirect.Extra[authn.KeyOAuthPKCE]; pkce != "" { if pkce := redirect.Extra[authn.KeyOAuthPKCE]; pkce != "" {
cookies.WriteCookie(reqCtx.Resp, OauthPKCECookieName, pkce, hs.Cfg.OAuthCookieMaxAge, hs.CookieOptionsFromCfg) cookies.WriteCookie(reqCtx.Resp, OauthPKCECookieName, pkce, hs.Cfg.OAuthCookieMaxAge, hs.CookieOptionsFromCfg)
} }
+2 -1
View File
@@ -123,7 +123,7 @@ func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Respons
} }
// Compute metadata // Compute metadata
pluginsMetadata := hs.getMultiAccessControlMetadata(c, pluginaccesscontrol.ScopeProvider.GetResourceScope(""), filteredPluginIDs) pluginsMetadata := getMultiAccessControlMetadata(c, pluginaccesscontrol.ScopeProvider.GetResourceScope(""), filteredPluginIDs)
// Prepare DTO // Prepare DTO
result := make(dtos.PluginList, 0) result := make(dtos.PluginList, 0)
@@ -201,6 +201,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
Includes: plugin.Includes, Includes: plugin.Includes,
BaseUrl: plugin.BaseURL, BaseUrl: plugin.BaseURL,
Module: plugin.Module, Module: plugin.Module,
ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), plugin),
DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL), DefaultNavUrl: path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL),
State: plugin.State, State: plugin.State,
Signature: plugin.Signature, Signature: plugin.Signature,
+6 -3
View File
@@ -27,6 +27,8 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/fakes" "github.com/grafana/grafana/pkg/plugins/manager/fakes"
"github.com/grafana/grafana/pkg/plugins/manager/filestore" "github.com/grafana/grafana/pkg/plugins/manager/filestore"
"github.com/grafana/grafana/pkg/plugins/manager/registry" "github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pfs" "github.com/grafana/grafana/pkg/plugins/pfs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/pluginscdn"
ac "github.com/grafana/grafana/pkg/services/accesscontrol" ac "github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -788,7 +790,6 @@ func Test_PluginsSettings(t *testing.T) {
Info: plugins.Info{ Info: plugins.Info{
Version: "1.0.0", Version: "1.0.0",
}}, plugins.ClassExternal, plugins.NewFakeFS()) }}, plugins.ClassExternal, plugins.NewFakeFS())
pluginRegistry := &fakes.FakePluginRegistry{ pluginRegistry := &fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{ Store: map[string]*plugins.Plugin{
p1.ID: p1, p1.ID: p1,
@@ -843,8 +844,10 @@ func Test_PluginsSettings(t *testing.T) {
ErrorCode: tc.errCode, ErrorCode: tc.errCode,
}) })
} }
pluginCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{}) pCfg := &config.PluginManagementCfg{}
hs.pluginAssets = pluginassets.ProvideService(hs.Cfg, pluginCDN) pluginCDN := pluginscdn.ProvideService(pCfg)
sig := signature.ProvideService(pCfg, statickey.New())
hs.pluginAssets = pluginassets.ProvideService(pCfg, pluginCDN, sig, hs.pluginStore)
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker) hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
var err error var err error
hs.pluginsUpdateChecker, err = updatechecker.ProvidePluginsService(hs.Cfg, nil, tracing.InitializeTracerForTest()) hs.pluginsUpdateChecker, err = updatechecker.ProvidePluginsService(hs.Cfg, nil, tracing.InitializeTracerForTest())
+1 -1
View File
@@ -91,7 +91,7 @@ func (hs *HTTPServer) getUserUserProfile(c *contextmodel.ReqContext, userID int6
userProfile.IsGrafanaAdminExternallySynced = login.IsGrafanaAdminExternallySynced(hs.Cfg, oauthInfo, authInfo.AuthModule) userProfile.IsGrafanaAdminExternallySynced = login.IsGrafanaAdminExternallySynced(hs.Cfg, oauthInfo, authInfo.AuthModule)
} }
userProfile.AccessControl = hs.getAccessControlMetadata(c, "global.users:id:", strconv.FormatInt(userID, 10)) userProfile.AccessControl = getAccessControlMetadata(c, "global.users:id:", strconv.FormatInt(userID, 10))
userProfile.AvatarURL = dtos.GetGravatarUrl(hs.Cfg, userProfile.Email) userProfile.AvatarURL = dtos.GetGravatarUrl(hs.Cfg, userProfile.Email)
return response.JSON(http.StatusOK, userProfile) return response.JSON(http.StatusOK, userProfile)
+19 -16
View File
@@ -1407,22 +1407,25 @@ func RunWatchSemantics(ctx context.Context, t *testing.T, store storage.Interfac
podsAfterEstablishingWatch: []*example.Pod{makePod("4"), makePod("5")}, podsAfterEstablishingWatch: []*example.Pod{makePod("4"), makePod("5")},
expectedEventsAfterEstablishingWatch: addEventsFromCreatedPods, expectedEventsAfterEstablishingWatch: addEventsFromCreatedPods,
}, },
// Not Supported by unistore because there is no way to differentiate between:
{ // - SendInitialEvents=nil && resourceVersion=0
name: "legacy, RV=0", // - sendInitialEvents=false && resourceVersion=0
resourceVersion: "0", // This is a Legacy feature in k8s.io/apiserver/pkg/storage/etcd3/watcher_test.go#196
initialPods: []*example.Pod{makePod("1"), makePod("2"), makePod("3")}, // {
expectedInitialEventsInRandomOrder: addEventsFromCreatedPods, // name: "legacy, RV=0",
podsAfterEstablishingWatch: []*example.Pod{makePod("4"), makePod("5")}, // resourceVersion: "0",
expectedEventsAfterEstablishingWatch: addEventsFromCreatedPods, // initialPods: []*example.Pod{makePod("1"), makePod("2"), makePod("3")},
}, // expectedInitialEventsInRandomOrder: addEventsFromCreatedPods,
{ // podsAfterEstablishingWatch: []*example.Pod{makePod("4"), makePod("5")},
name: "legacy, RV=unset", // expectedEventsAfterEstablishingWatch: addEventsFromCreatedPods,
initialPods: []*example.Pod{makePod("1"), makePod("2"), makePod("3")}, // },
expectedInitialEventsInRandomOrder: addEventsFromCreatedPods, // {
podsAfterEstablishingWatch: []*example.Pod{makePod("4"), makePod("5")}, // name: "legacy, RV=unset",
expectedEventsAfterEstablishingWatch: addEventsFromCreatedPods, // initialPods: []*example.Pod{makePod("1"), makePod("2"), makePod("3")},
}, // expectedInitialEventsInRandomOrder: addEventsFromCreatedPods,
// podsAfterEstablishingWatch: []*example.Pod{makePod("4"), makePod("5")},
// expectedEventsAfterEstablishingWatch: addEventsFromCreatedPods,
// },
} }
for idx, scenario := range scenarios { for idx, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
+5 -4
View File
@@ -98,7 +98,7 @@ func writeRedirectCookie(c *contextmodel.ReqContext) {
} }
// remove any forceLogin=true params // remove any forceLogin=true params
redirectTo = removeForceLoginParams(redirectTo) redirectTo = RemoveForceLoginParams(redirectTo)
cookies.WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil) cookies.WriteCookie(c.Resp, "redirect_to", url.QueryEscape(redirectTo), 0, nil)
} }
@@ -113,13 +113,13 @@ func getRedirectToQueryParam(c *contextmodel.ReqContext) string {
} }
// remove any forceLogin=true params // remove any forceLogin=true params
redirectTo = removeForceLoginParams(redirectTo) redirectTo = RemoveForceLoginParams(redirectTo)
return "?redirectTo=" + url.QueryEscape(redirectTo) return "?redirectTo=" + url.QueryEscape(redirectTo)
} }
var forceLoginParamsRegexp = regexp.MustCompile(`&?forceLogin=true`) var forceLoginParamsRegexp = regexp.MustCompile(`&?forceLogin=true`)
func removeForceLoginParams(str string) string { func RemoveForceLoginParams(str string) string {
return forceLoginParamsRegexp.ReplaceAllString(str, "") return forceLoginParamsRegexp.ReplaceAllString(str, "")
} }
@@ -138,7 +138,8 @@ func CanAdminPlugins(cfg *setting.Cfg, accessControl ac.AccessControl) func(c *c
} }
func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, features featuremgmt.FeatureToggles, func RoleAppPluginAuth(accessControl ac.AccessControl, ps pluginstore.Store, features featuremgmt.FeatureToggles,
logger log.Logger) func(c *contextmodel.ReqContext) { logger log.Logger,
) func(c *contextmodel.ReqContext) {
return func(c *contextmodel.ReqContext) { return func(c *contextmodel.ReqContext) {
pluginID := web.Params(c.Req)[":id"] pluginID := web.Params(c.Req)[":id"]
p, exists := ps.Plugin(c.Req.Context(), pluginID) p, exists := ps.Plugin(c.Req.Context(), pluginID)
+1 -1
View File
@@ -352,7 +352,7 @@ func TestRemoveForceLoginparams(t *testing.T) {
} }
for i, tc := range tcs { for i, tc := range tcs {
t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) { t.Run(fmt.Sprintf("testcase %d", i), func(t *testing.T) {
require.Equal(t, tc.exp, removeForceLoginParams(tc.inp)) require.Equal(t, tc.exp, RemoveForceLoginParams(tc.inp))
}) })
} }
} }
+14 -13
View File
@@ -22,19 +22,20 @@ func (e *TokenRevokedError) Unwrap() error { return ErrInvalidSessionToken }
// UserToken represents a user token // UserToken represents a user token
type UserToken struct { type UserToken struct {
Id int64 Id int64
UserId int64 UserId int64
AuthToken string ExternalSessionId int64
PrevAuthToken string AuthToken string
UserAgent string PrevAuthToken string
ClientIp string UserAgent string
AuthTokenSeen bool ClientIp string
SeenAt int64 AuthTokenSeen bool
RotatedAt int64 SeenAt int64
CreatedAt int64 RotatedAt int64
UpdatedAt int64 CreatedAt int64
RevokedAt int64 UpdatedAt int64
UnhashedToken string RevokedAt int64
UnhashedToken string
} }
const UrgentRotateTime = 1 * time.Minute const UrgentRotateTime = 1 * time.Minute
+1
View File
@@ -32,6 +32,7 @@ type PluginManagementCfg struct {
type Features struct { type Features struct {
ExternalCorePluginsEnabled bool ExternalCorePluginsEnabled bool
SkipHostEnvVarsEnabled bool SkipHostEnvVarsEnabled bool
SriChecksEnabled bool
} }
// NewPluginManagementCfg returns a new PluginManagementCfg. // NewPluginManagementCfg returns a new PluginManagementCfg.
+41 -32
View File
@@ -53,7 +53,7 @@ type PluginManifest struct {
RootURLs []string `json:"rootUrls"` RootURLs []string `json:"rootUrls"`
} }
func (m *PluginManifest) isV2() bool { func (m *PluginManifest) IsV2() bool {
return strings.HasPrefix(m.ManifestVersion, "2.") return strings.HasPrefix(m.ManifestVersion, "2.")
} }
@@ -107,34 +107,17 @@ func (s *Signature) readPluginManifest(ctx context.Context, body []byte) (*Plugi
return &manifest, nil return &manifest, nil
} }
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) { var ErrSignatureTypeUnsigned = errors.New("plugin is unsigned")
if defaultSignature, exists := src.DefaultSignature(ctx); exists {
return defaultSignature, nil
}
fsFiles, err := plugin.FS.Files()
if err != nil {
return plugins.Signature{}, fmt.Errorf("files: %w", err)
}
if len(fsFiles) == 0 {
s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}
f, err := plugin.FS.Open("MANIFEST.txt") // ReadPluginManifestFromFS reads the plugin manifest from the provided plugins.FS.
// If the manifest is not found, it will return an error wrapping ErrSignatureTypeUnsigned.
func (s *Signature) ReadPluginManifestFromFS(ctx context.Context, pfs plugins.FS) (*PluginManifest, error) {
f, err := pfs.Open("MANIFEST.txt")
if err != nil { if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) { if errors.Is(err, plugins.ErrFileNotExist) {
s.log.Debug("Could not find a MANIFEST.txt", "id", plugin.JSONData.ID, "error", err) return nil, fmt.Errorf("%w: could not find a MANIFEST.txt", ErrSignatureTypeUnsigned)
return plugins.Signature{
Status: plugins.SignatureStatusUnsigned,
}, nil
} }
return nil, fmt.Errorf("could not open MANIFEST.txt: %w", err)
s.log.Debug("Could not open MANIFEST.txt", "id", plugin.JSONData.ID, "error", err)
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
} }
defer func() { defer func() {
if f == nil { if f == nil {
@@ -147,21 +130,47 @@ func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plu
byteValue, err := io.ReadAll(f) byteValue, err := io.ReadAll(f)
if err != nil || len(byteValue) < 10 { if err != nil || len(byteValue) < 10 {
s.log.Debug("MANIFEST.TXT is invalid", "id", plugin.JSONData.ID) return nil, fmt.Errorf("%w: MANIFEST.txt is invalid", ErrSignatureTypeUnsigned)
return plugins.Signature{
Status: plugins.SignatureStatusUnsigned,
}, nil
} }
manifest, err := s.readPluginManifest(ctx, byteValue) manifest, err := s.readPluginManifest(ctx, byteValue)
if err != nil { if err != nil {
s.log.Warn("Plugin signature invalid", "id", plugin.JSONData.ID, "error", err) return nil, err
}
return manifest, nil
}
func (s *Signature) Calculate(ctx context.Context, src plugins.PluginSource, plugin plugins.FoundPlugin) (plugins.Signature, error) {
if defaultSignature, exists := src.DefaultSignature(ctx); exists {
return defaultSignature, nil
}
manifest, err := s.ReadPluginManifestFromFS(ctx, plugin.FS)
switch {
case errors.Is(err, ErrSignatureTypeUnsigned):
s.log.Warn("Plugin is unsigned", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{
Status: plugins.SignatureStatusUnsigned,
}, nil
case err != nil:
s.log.Warn("Plugin signature is invalid", "id", plugin.JSONData.ID, "err", err)
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureStatusInvalid, Status: plugins.SignatureStatusInvalid,
}, nil }, nil
} }
if !manifest.isV2() { if !manifest.IsV2() {
return plugins.Signature{
Status: plugins.SignatureStatusInvalid,
}, nil
}
fsFiles, err := plugin.FS.Files()
if err != nil {
return plugins.Signature{}, fmt.Errorf("files: %w", err)
}
if len(fsFiles) == 0 {
s.log.Warn("No plugin file information in directory", "pluginId", plugin.JSONData.ID)
return plugins.Signature{ return plugins.Signature{
Status: plugins.SignatureStatusInvalid, Status: plugins.SignatureStatusInvalid,
}, nil }, nil
@@ -328,7 +337,7 @@ func (s *Signature) validateManifest(ctx context.Context, m PluginManifest, bloc
if len(m.Files) == 0 { if len(m.Files) == 0 {
return invalidFieldErr{field: "files"} return invalidFieldErr{field: "files"}
} }
if m.isV2() { if m.IsV2() {
if len(m.SignedByOrg) == 0 { if len(m.SignedByOrg) == 0 {
return invalidFieldErr{field: "signedByOrg"} return invalidFieldErr{field: "signedByOrg"}
} }
+21 -13
View File
@@ -19,6 +19,14 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
) )
func provideDefaultTestService() *Signature {
return provideTestServiceWithConfig(&config.PluginManagementCfg{})
}
func provideTestServiceWithConfig(cfg *config.PluginManagementCfg) *Signature {
return ProvideService(cfg, statickey.New())
}
func TestReadPluginManifest(t *testing.T) { func TestReadPluginManifest(t *testing.T) {
txt := `-----BEGIN PGP SIGNED MESSAGE----- txt := `-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512 Hash: SHA512
@@ -52,7 +60,7 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX
-----END PGP SIGNATURE-----` -----END PGP SIGNATURE-----`
t.Run("valid manifest", func(t *testing.T) { t.Run("valid manifest", func(t *testing.T) {
s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) s := provideDefaultTestService()
manifest, err := s.readPluginManifest(context.Background(), []byte(txt)) manifest, err := s.readPluginManifest(context.Background(), []byte(txt))
require.NoError(t, err) require.NoError(t, err)
@@ -68,8 +76,8 @@ NR7DnB0CCQHO+4FlSPtXFTzNepoc+CytQyDAeOLMLmf2Tqhk2YShk+G/YlVX
}) })
t.Run("invalid manifest", func(t *testing.T) { t.Run("invalid manifest", func(t *testing.T) {
s := provideDefaultTestService()
modified := strings.ReplaceAll(txt, "README.md", "xxxxxxxxxx") modified := strings.ReplaceAll(txt, "README.md", "xxxxxxxxxx")
s := ProvideService(&config.PluginManagementCfg{}, statickey.New())
_, err := s.readPluginManifest(context.Background(), []byte(modified)) _, err := s.readPluginManifest(context.Background(), []byte(modified))
require.Error(t, err) require.Error(t, err)
}) })
@@ -107,7 +115,7 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI=
-----END PGP SIGNATURE-----` -----END PGP SIGNATURE-----`
t.Run("valid manifest", func(t *testing.T) { t.Run("valid manifest", func(t *testing.T) {
s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) s := provideDefaultTestService()
manifest, err := s.readPluginManifest(context.Background(), []byte(txt)) manifest, err := s.readPluginManifest(context.Background(), []byte(txt))
require.NoError(t, err) require.NoError(t, err)
@@ -126,6 +134,12 @@ khdr/tZ1PDgRxMqB/u+Vtbpl0xSxgblnrDOYMSI=
} }
func TestCalculate(t *testing.T) { func TestCalculate(t *testing.T) {
parentDir, err := filepath.Abs("../")
if err != nil {
t.Errorf("could not construct absolute path of current dir")
return
}
t.Run("Validate root URL against App URL for non-private plugin if is specified in manifest", func(t *testing.T) { t.Run("Validate root URL against App URL for non-private plugin if is specified in manifest", func(t *testing.T) {
tcs := []struct { tcs := []struct {
appURL string appURL string
@@ -147,15 +161,9 @@ func TestCalculate(t *testing.T) {
}, },
} }
parentDir, err := filepath.Abs("../")
if err != nil {
t.Errorf("could not construct absolute path of current dir")
return
}
for _, tc := range tcs { for _, tc := range tcs {
basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin") basePath := filepath.Join(parentDir, "testdata/non-pvt-with-root-url/plugin")
s := ProvideService(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL}, statickey.New()) s := provideTestServiceWithConfig(&config.PluginManagementCfg{GrafanaAppURL: tc.appURL})
sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class { PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal return plugins.ClassExternal
@@ -183,7 +191,7 @@ func TestCalculate(t *testing.T) {
basePath := "../testdata/renderer-added-file/plugin" basePath := "../testdata/renderer-added-file/plugin"
runningWindows = true runningWindows = true
s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) s := provideDefaultTestService()
sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{ sig, err := s.Calculate(context.Background(), &fakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class { PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal return plugins.ClassExternal
@@ -247,7 +255,7 @@ func TestCalculate(t *testing.T) {
toSlash = tc.platform.toSlashFunc() toSlash = tc.platform.toSlashFunc()
fromSlash = tc.platform.fromSlashFunc() fromSlash = tc.platform.fromSlashFunc()
s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) s := provideDefaultTestService()
pfs, err := tc.fsFactory() pfs, err := tc.fsFactory()
require.NoError(t, err) require.NoError(t, err)
pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs) pfs, err = newPathSeparatorOverrideFS(string(tc.platform.separator), pfs)
@@ -721,7 +729,7 @@ func Test_validateManifest(t *testing.T) {
} }
for _, tc := range tcs { for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
s := ProvideService(&config.PluginManagementCfg{}, statickey.New()) s := provideDefaultTestService()
err := s.validateManifest(context.Background(), *tc.manifest, nil) err := s.validateManifest(context.Background(), *tc.manifest, nil)
require.Errorf(t, err, tc.expectedErr) require.Errorf(t, err, tc.expectedErr)
}) })
+3
View File
@@ -262,6 +262,7 @@ type PluginMetaDTO struct {
JSONData JSONData
Signature SignatureStatus `json:"signature"` Signature SignatureStatus `json:"signature"`
Module string `json:"module"` Module string `json:"module"`
ModuleHash string `json:"moduleHash,omitempty"`
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
Angular AngularMeta `json:"angular"` Angular AngularMeta `json:"angular"`
MultiValueFilterOperators bool `json:"multiValueFilterOperators"` MultiValueFilterOperators bool `json:"multiValueFilterOperators"`
@@ -314,6 +315,7 @@ type PanelDTO struct {
Module string `json:"module"` Module string `json:"module"`
Angular AngularMeta `json:"angular"` Angular AngularMeta `json:"angular"`
LoadingStrategy LoadingStrategy `json:"loadingStrategy"` LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
ModuleHash string `json:"moduleHash,omitempty"`
} }
type AppDTO struct { type AppDTO struct {
@@ -325,6 +327,7 @@ type AppDTO struct {
LoadingStrategy LoadingStrategy `json:"loadingStrategy"` LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
Extensions Extensions `json:"extensions"` Extensions Extensions `json:"extensions"`
Dependencies Dependencies `json:"dependencies"` Dependencies Dependencies `json:"dependencies"`
ModuleHash string `json:"moduleHash,omitempty"`
} }
const ( const (
+55 -16
View File
@@ -50,13 +50,47 @@ func LegacyUpdateCommandToUnstructured(cmd folder.UpdateFolderCommand) unstructu
return obj return obj
} }
func UnstructuredToLegacyFolder(item unstructured.Unstructured) *folder.Folder { func UnstructuredToLegacyFolder(item unstructured.Unstructured, orgID int64) *folder.Folder {
// #TODO reduce duplication of the different conversion functions
spec := item.Object["spec"].(map[string]any) spec := item.Object["spec"].(map[string]any)
return &folder.Folder{ uid := item.GetName()
UID: item.GetName(), title := spec["title"].(string)
Title: spec["title"].(string),
// #TODO add other fields meta, err := utils.MetaAccessor(&item)
if err != nil {
return nil
} }
id, err := getLegacyID(meta)
if err != nil {
return nil
}
created, err := getCreated(meta)
if err != nil {
return nil
}
// avoid panic
var createdTime time.Time
if created != nil {
createdTime = created.Local()
}
f := &folder.Folder{
UID: uid,
Title: title,
ID: id,
ParentUID: meta.GetFolder(),
// #TODO add created by field if necessary
// CreatedBy: meta.GetCreatedBy(),
// UpdatedBy: meta.GetCreatedBy(),
URL: getURL(meta, title),
Created: createdTime,
Updated: createdTime,
OrgID: orgID,
}
return f
} }
func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) (*dtos.Folder, error) { func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) (*dtos.Folder, error) {
@@ -79,6 +113,14 @@ func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) (*dtos.Folder
return nil, err return nil, err
} }
// avoid panic
var createdTime time.Time
if created != nil {
// #TODO Fix this time format. The legacy time format seems to be along the lines of time.Now()
// which includes a part that represents a fraction of a second.
createdTime = created.Local()
}
dto := &dtos.Folder{ dto := &dtos.Folder{
UID: uid, UID: uid,
Title: title, Title: title,
@@ -87,20 +129,14 @@ func UnstructuredToLegacyFolderDTO(item unstructured.Unstructured) (*dtos.Folder
// #TODO add back CreatedBy, UpdatedBy once we figure out how to access userService // #TODO add back CreatedBy, UpdatedBy once we figure out how to access userService
// to translate user ID into user login. meta.GetCreatedBy() only stores user ID // to translate user ID into user login. meta.GetCreatedBy() only stores user ID
// Could convert meta.GetCreatedBy() return value to a struct--id and name // Could convert meta.GetCreatedBy() return value to a struct--id and name
// CreatedBy: meta.GetCreatedBy(), CreatedBy: meta.GetCreatedBy(),
// UpdatedBy: meta.GetCreatedBy(), UpdatedBy: meta.GetCreatedBy(),
URL: getURL(meta, title), URL: getURL(meta, title),
// #TODO get Created in format "2024-09-12T15:37:41.09466+02:00" // #TODO get Created in format "2024-09-12T15:37:41.09466+02:00"
Created: *created, Created: createdTime,
// #TODO figure out whether we want to set "updated" and "updated by". Could replace with // #TODO figure out whether we want to set "updated" and "updated by". Could replace with
// meta.GetUpdatedTimestamp() but it currently gets overwritten in prepareObjectForStorage(). // meta.GetUpdatedTimestamp() but it currently gets overwritten in prepareObjectForStorage().
Updated: *created, Updated: createdTime,
// #TODO figure out how to set these properly
CanSave: true,
CanEdit: true,
CanAdmin: true,
CanDelete: true,
HasACL: false,
// #TODO figure out about adding version, parents, orgID fields // #TODO figure out about adding version, parents, orgID fields
} }
@@ -135,6 +171,9 @@ func convertToK8sResource(v *folder.Folder, namespacer request.NamespaceMapper)
Timestamp: &v.Created, Timestamp: &v.Created,
}) })
} }
// #TODO: turns out these get overwritten by Unified Storage (see pkg/storage/unified/apistore/prepare.go)
// We're going to have to align with that. For now we do need the user ID because the folder type stores it
// as the only user identifier
if v.CreatedBy > 0 { if v.CreatedBy > 0 {
meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy)) meta.SetCreatedBy(fmt.Sprintf("user:%d", v.CreatedBy))
} }
+3
View File
@@ -149,6 +149,7 @@ import (
"github.com/grafana/grafana/pkg/services/team/teamimpl" "github.com/grafana/grafana/pkg/services/team/teamimpl"
tempuser "github.com/grafana/grafana/pkg/services/temp_user" tempuser "github.com/grafana/grafana/pkg/services/temp_user"
"github.com/grafana/grafana/pkg/services/temp_user/tempuserimpl" "github.com/grafana/grafana/pkg/services/temp_user/tempuserimpl"
"github.com/grafana/grafana/pkg/services/unifiedSearch"
"github.com/grafana/grafana/pkg/services/updatechecker" "github.com/grafana/grafana/pkg/services/updatechecker"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/services/user/userimpl"
@@ -229,6 +230,8 @@ var wireBasicSet = wire.NewSet(
search.ProvideService, search.ProvideService,
searchV2.ProvideService, searchV2.ProvideService,
searchV2.ProvideSearchHTTPService, searchV2.ProvideSearchHTTPService,
unifiedSearch.ProvideService,
unifiedSearch.ProvideSearchHTTPService,
store.ProvideService, store.ProvideService,
store.ProvideSystemUsersService, store.ProvideSystemUsersService,
live.ProvideService, live.ProvideService,
+6 -4
View File
@@ -173,10 +173,11 @@ type TeamRole struct {
} }
type UserRole struct { type UserRole struct {
ID int64 `json:"id" xorm:"pk autoincr 'id'"` ID int64 `json:"id" xorm:"pk autoincr 'id'"`
OrgID int64 `json:"orgId" xorm:"org_id"` OrgID int64 `json:"orgId" xorm:"org_id"`
RoleID int64 `json:"roleId" xorm:"role_id"` RoleID int64 `json:"roleId" xorm:"role_id"`
UserID int64 `json:"userId" xorm:"user_id"` UserID int64 `json:"userId" xorm:"user_id"`
GroupMappingUID string `json:"groupMappingUID" xorm:"group_mapping_uid"`
Created time.Time Created time.Time
} }
@@ -455,6 +456,7 @@ const (
ActionAlertingReceiversCreate = "alert.notifications.receivers:create" ActionAlertingReceiversCreate = "alert.notifications.receivers:create"
ActionAlertingReceiversUpdate = "alert.notifications.receivers:write" ActionAlertingReceiversUpdate = "alert.notifications.receivers:write"
ActionAlertingReceiversDelete = "alert.notifications.receivers:delete" ActionAlertingReceiversDelete = "alert.notifications.receivers:delete"
ActionAlertingReceiversTest = "alert.notifications.receivers:test"
ActionAlertingReceiversPermissionsRead = "receivers.permissions:read" ActionAlertingReceiversPermissionsRead = "receivers.permissions:read"
ActionAlertingReceiversPermissionsWrite = "receivers.permissions:write" ActionAlertingReceiversPermissionsWrite = "receivers.permissions:write"
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
@@ -42,21 +43,27 @@ func (a *api) registerEndpoints() {
licenseMW = nopMiddleware licenseMW = nopMiddleware
} }
teamUIDResolver := team.MiddlewareTeamUIDResolver(a.service.teamService, ":teamID")
teamUIDResolverResource := func() web.Handler { return func(c *contextmodel.ReqContext) {} }() // no-op
if a.service.options.Resource == "teams" {
teamUIDResolverResource = team.MiddlewareTeamUIDResolver(a.service.teamService, ":resourceID")
}
a.router.Group(fmt.Sprintf("/api/access-control/%s", a.service.options.Resource), func(r routing.RouteRegister) { a.router.Group(fmt.Sprintf("/api/access-control/%s", a.service.options.Resource), func(r routing.RouteRegister) {
actionRead := fmt.Sprintf("%s.permissions:read", a.service.options.Resource) actionRead := fmt.Sprintf("%s.permissions:read", a.service.options.Resource)
actionWrite := fmt.Sprintf("%s.permissions:write", a.service.options.Resource) actionWrite := fmt.Sprintf("%s.permissions:write", a.service.options.Resource)
scope := accesscontrol.Scope(a.service.options.Resource, a.service.options.ResourceAttribute, accesscontrol.Parameter(":resourceID")) scope := accesscontrol.Scope(a.service.options.Resource, a.service.options.ResourceAttribute, accesscontrol.Parameter(":resourceID"))
r.Get("/description", auth(accesscontrol.EvalPermission(actionRead)), routing.Wrap(a.getDescription)) r.Get("/description", auth(accesscontrol.EvalPermission(actionRead)), routing.Wrap(a.getDescription))
r.Get("/:resourceID", auth(accesscontrol.EvalPermission(actionRead, scope)), routing.Wrap(a.getPermissions)) r.Get("/:resourceID", teamUIDResolverResource, auth(accesscontrol.EvalPermission(actionRead, scope)), routing.Wrap(a.getPermissions))
r.Post("/:resourceID", licenseMW, auth(accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setPermissions)) r.Post("/:resourceID", teamUIDResolverResource, licenseMW, auth(accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setPermissions))
if a.service.options.Assignments.Users { if a.service.options.Assignments.Users {
r.Post("/:resourceID/users/:userID", licenseMW, auth(accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setUserPermission)) r.Post("/:resourceID/users/:userID", licenseMW, teamUIDResolverResource, auth(accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setUserPermission))
} }
if a.service.options.Assignments.Teams { if a.service.options.Assignments.Teams {
r.Post("/:resourceID/teams/:teamID", licenseMW, auth(accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setTeamPermission)) r.Post("/:resourceID/teams/:teamID", licenseMW, teamUIDResolverResource, teamUIDResolver, auth(accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setTeamPermission))
} }
if a.service.options.Assignments.BuiltInRoles { if a.service.options.Assignments.BuiltInRoles {
r.Post("/:resourceID/builtInRoles/:builtInRole", licenseMW, auth(accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setBuiltinRolePermission)) r.Post("/:resourceID/builtInRoles/:builtInRole", teamUIDResolverResource, licenseMW, auth(accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setBuiltinRolePermission))
} }
}) })
} }
@@ -257,6 +257,7 @@ type setTeamPermissionTestCase struct {
expectedStatus int expectedStatus int
permission string permission string
permissions []accesscontrol.Permission permissions []accesscontrol.Permission
byUID bool
} }
func TestApi_setTeamPermission(t *testing.T) { func TestApi_setTeamPermission(t *testing.T) {
@@ -308,6 +309,20 @@ func TestApi_setTeamPermission(t *testing.T) {
{Action: "dashboards.permissions:read", Scope: "dashboards:id:1"}, {Action: "dashboards.permissions:read", Scope: "dashboards:id:1"},
}, },
}, },
{
desc: "should set View permission for team with id 1 but through UID",
teamID: 1,
resourceID: "1",
expectedStatus: 200,
permission: "View",
byUID: true,
permissions: []accesscontrol.Permission{
{Action: "dashboards.permissions:read", Scope: "dashboards:id:1"},
{Action: "dashboards.permissions:write", Scope: "dashboards:id:1"},
{Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll},
{Action: accesscontrol.ActionOrgUsersRead, Scope: accesscontrol.ScopeUsersAll},
},
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -316,10 +331,16 @@ func TestApi_setTeamPermission(t *testing.T) {
server := setupTestServer(t, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByActionContext(context.Background(), tt.permissions)}}, service) server := setupTestServer(t, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByActionContext(context.Background(), tt.permissions)}}, service)
// seed team // seed team
_, err := teamSvc.CreateTeam(context.Background(), "test", "test@test.com", 1) team, err := teamSvc.CreateTeam(context.Background(), "test", "test@test.com", 1)
require.NoError(t, err) require.NoError(t, err)
recorder := setPermission(t, server, testOptions.Resource, tt.resourceID, tt.permission, "teams", strconv.Itoa(int(tt.teamID))) assignTo := strconv.Itoa(int(tt.teamID))
if tt.byUID {
if team.ID == tt.teamID {
assignTo = team.UID
}
}
recorder := setPermission(t, server, testOptions.Resource, tt.resourceID, tt.permission, "teams", assignTo)
assert.Equal(t, tt.expectedStatus, recorder.Code) assert.Equal(t, tt.expectedStatus, recorder.Code)
assert.Equal(t, tt.expectedStatus, recorder.Code) assert.Equal(t, tt.expectedStatus, recorder.Code)
+4 -3
View File
@@ -17,8 +17,9 @@ import (
) )
var ( var (
errInvalidOrg = errutil.Unauthorized("anonymous.invalid-org") errInvalidOrg = errutil.Unauthorized("anonymous.invalid-org")
errInvalidID = errutil.Unauthorized("anonymous.invalid-id") errInvalidID = errutil.Unauthorized("anonymous.invalid-id")
errDeviceLimit = errutil.Unauthorized("anonymous.device-limit-reached", errutil.WithPublicMessage("Anonymous device limit reached. Contact Administrator"))
) )
var _ authn.ContextAwareClient = new(Anonymous) var _ authn.ContextAwareClient = new(Anonymous)
@@ -51,7 +52,7 @@ func (a *Anonymous) Authenticate(ctx context.Context, r *authn.Request) (*authn.
if err := a.anonDeviceService.TagDevice(ctx, httpReqCopy, anonymous.AnonDeviceUI); err != nil { if err := a.anonDeviceService.TagDevice(ctx, httpReqCopy, anonymous.AnonDeviceUI); err != nil {
if errors.Is(err, anonstore.ErrDeviceLimitReached) { if errors.Is(err, anonstore.ErrDeviceLimitReached) {
return nil, err return nil, errDeviceLimit.Errorf("limit reached for anonymous devices: %w", err)
} }
a.log.Warn("Failed to tag anonymous session", "error", err) a.log.Warn("Failed to tag anonymous session", "error", err)
+14 -4
View File
@@ -2,6 +2,7 @@ package anonimpl
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"time" "time"
@@ -79,20 +80,29 @@ func (a *AnonDeviceService) usageStatFn(ctx context.Context) (map[string]any, er
}, nil }, nil
} }
func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, httpReq *http.Request, device *anonstore.Device) error { func (a *AnonDeviceService) tagDeviceUI(ctx context.Context, device *anonstore.Device) error {
key := device.CacheKey() key := device.CacheKey()
if _, ok := a.localCache.Get(key); ok { if val, ok := a.localCache.Get(key); ok {
if boolVal, ok := val.(bool); ok && !boolVal {
return anonstore.ErrDeviceLimitReached
}
return nil return nil
} }
a.localCache.SetDefault(key, struct{}{}) a.localCache.SetDefault(key, true)
if a.cfg.Env == setting.Dev { if a.cfg.Env == setting.Dev {
a.log.Debug("Tagging device for UI", "deviceID", device.DeviceID, "device", device, "key", key) a.log.Debug("Tagging device for UI", "deviceID", device.DeviceID, "device", device, "key", key)
} }
if err := a.anonStore.CreateOrUpdateDevice(ctx, device); err != nil { if err := a.anonStore.CreateOrUpdateDevice(ctx, device); err != nil {
if errors.Is(err, anonstore.ErrDeviceLimitReached) {
a.localCache.SetDefault(key, false)
return err
}
// invalidate cache if there is an error
a.localCache.Delete(key)
return err return err
} }
@@ -142,7 +152,7 @@ func (a *AnonDeviceService) TagDevice(ctx context.Context, httpReq *http.Request
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
err = a.tagDeviceUI(ctx, httpReq, taggedDevice) err = a.tagDeviceUI(ctx, taggedDevice)
if err != nil { if err != nil {
a.log.Debug("Failed to tag device for UI", "error", err) a.log.Debug("Failed to tag device for UI", "error", err)
return err return err
@@ -26,6 +26,10 @@ func TestMain(m *testing.M) {
} }
func TestIntegrationDeviceService_tag(t *testing.T) { func TestIntegrationDeviceService_tag(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode")
}
type tagReq struct { type tagReq struct {
httpReq *http.Request httpReq *http.Request
kind anonymous.DeviceKind kind anonymous.DeviceKind
@@ -152,6 +156,9 @@ func TestIntegrationDeviceService_tag(t *testing.T) {
// Ensure that the local cache prevents request from being tagged // Ensure that the local cache prevents request from being tagged
func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) { func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode")
}
store := db.InitTestDB(t) store := db.InitTestDB(t)
anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{}, anonService := ProvideAnonymousDeviceService(&usagestats.UsageStatsMock{},
&authntest.FakeService{}, store, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{}) &authntest.FakeService{}, store, setting.NewCfg(), orgtest.NewOrgServiceFake(), nil, actest.FakeAccessControl{}, &routing.RouteRegisterImpl{})
@@ -184,6 +191,10 @@ func TestIntegrationAnonDeviceService_localCacheSafety(t *testing.T) {
} }
func TestIntegrationDeviceService_SearchDevice(t *testing.T) { func TestIntegrationDeviceService_SearchDevice(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode")
}
fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) // Fixed timestamp for testing fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) // Fixed timestamp for testing
testCases := []struct { testCases := []struct {
@@ -271,3 +282,88 @@ func TestIntegrationDeviceService_SearchDevice(t *testing.T) {
}) })
} }
} }
func TestIntegrationAnonDeviceService_DeviceLimitWithCache(t *testing.T) {
if testing.Short() {
t.Skip("skipping test in short mode")
}
// Setup test environment
store := db.InitTestDB(t)
cfg := setting.NewCfg()
cfg.AnonymousDeviceLimit = 1 // Set device limit to 1 for testing
anonService := ProvideAnonymousDeviceService(
&usagestats.UsageStatsMock{},
&authntest.FakeService{},
store,
cfg,
orgtest.NewOrgServiceFake(),
nil,
actest.FakeAccessControl{},
&routing.RouteRegisterImpl{},
)
// Define test cases
testCases := []struct {
name string
httpReq *http.Request
expectedErr error
}{
{
name: "first request should succeed",
httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.1"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"device1"},
},
},
expectedErr: nil,
},
{
name: "second request should fail due to device limit",
httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.2"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"device2"},
},
},
expectedErr: anonstore.ErrDeviceLimitReached,
},
{
name: "repeat request should hit cache and succeed",
httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.1"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"device1"},
},
},
expectedErr: nil,
},
{
name: "third request should hit cache and fail due to device limit",
httpReq: &http.Request{
Header: http.Header{
"User-Agent": []string{"test"},
"X-Forwarded-For": []string{"10.30.30.2"},
http.CanonicalHeaderKey(deviceIDHeader): []string{"device2"},
},
},
expectedErr: anonstore.ErrDeviceLimitReached,
},
}
// Run test cases
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := anonService.TagDevice(context.Background(), tc.httpReq, anonymous.AnonDeviceUI)
if tc.expectedErr != nil {
require.Error(t, err)
assert.Equal(t, tc.expectedErr, err)
} else {
require.NoError(t, err)
}
})
}
}
+15 -3
View File
@@ -20,8 +20,9 @@ const (
// Typed errors // Typed errors
var ( var (
ErrUserTokenNotFound = errors.New("user token not found") ErrUserTokenNotFound = errors.New("user token not found")
ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken
ErrExternalSessionNotFound = errors.New("external session not found")
) )
type ( type (
@@ -65,10 +66,21 @@ type RotateCommand struct {
UserAgent string UserAgent string
} }
type CreateTokenCommand struct {
User *user.User
ClientIP net.IP
UserAgent string
ExternalSession *ExternalSession
}
// UserTokenService are used for generating and validating user tokens // UserTokenService are used for generating and validating user tokens
type UserTokenService interface { type UserTokenService interface {
CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*UserToken, error) CreateToken(ctx context.Context, cmd *CreateTokenCommand) (*UserToken, error)
LookupToken(ctx context.Context, unhashedToken string) (*UserToken, error) LookupToken(ctx context.Context, unhashedToken string) (*UserToken, error)
GetTokenByExternalSessionID(ctx context.Context, externalSessionID int64) (*UserToken, error)
GetExternalSession(ctx context.Context, extSessionID int64) (*ExternalSession, error)
FindExternalSessions(ctx context.Context, query *ListExternalSessionQuery) ([]*ExternalSession, error)
// RotateToken will always rotate a valid token // RotateToken will always rotate a valid token
RotateToken(ctx context.Context, cmd RotateCommand) (*UserToken, error) RotateToken(ctx context.Context, cmd RotateCommand) (*UserToken, error)
RevokeToken(ctx context.Context, token *UserToken, soft bool) error RevokeToken(ctx context.Context, token *UserToken, soft bool) error
+115 -38
View File
@@ -14,10 +14,11 @@ import (
"github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock" "github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/models/usertoken"
"github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
) )
@@ -28,10 +29,13 @@ var (
errUserIDInvalid = errors.New("invalid user ID") errUserIDInvalid = errors.New("invalid user ID")
) )
var _ auth.UserTokenService = (*UserAuthTokenService)(nil)
func ProvideUserAuthTokenService(sqlStore db.DB, func ProvideUserAuthTokenService(sqlStore db.DB,
serverLockService *serverlock.ServerLockService, serverLockService *serverlock.ServerLockService,
quotaService quota.Service, quotaService quota.Service, secretService secrets.Service,
cfg *setting.Cfg) (*UserAuthTokenService, error) { cfg *setting.Cfg, tracer tracing.Tracer,
) (*UserAuthTokenService, error) {
s := &UserAuthTokenService{ s := &UserAuthTokenService{
sqlStore: sqlStore, sqlStore: sqlStore,
serverLockService: serverLockService, serverLockService: serverLockService,
@@ -39,6 +43,7 @@ func ProvideUserAuthTokenService(sqlStore db.DB,
log: log.New("auth"), log: log.New("auth"),
singleflight: new(singleflight.Group), singleflight: new(singleflight.Group),
} }
s.externalSessionStore = provideExternalSessionStore(sqlStore, secretService, tracer)
defaultLimits, err := readQuotaConfig(cfg) defaultLimits, err := readQuotaConfig(cfg)
if err != nil { if err != nil {
@@ -57,31 +62,32 @@ func ProvideUserAuthTokenService(sqlStore db.DB,
} }
type UserAuthTokenService struct { type UserAuthTokenService struct {
sqlStore db.DB sqlStore db.DB
serverLockService *serverlock.ServerLockService serverLockService *serverlock.ServerLockService
cfg *setting.Cfg cfg *setting.Cfg
log log.Logger log log.Logger
singleflight *singleflight.Group externalSessionStore auth.ExternalSessionStore
singleflight *singleflight.Group
} }
func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { func (s *UserAuthTokenService) CreateToken(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error) {
token, hashedToken, err := generateAndHashToken(s.cfg.SecretKey) token, hashedToken, err := generateAndHashToken(s.cfg.SecretKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
now := getTime().Unix() now := getTime().Unix()
clientIPStr := clientIP.String() clientIPStr := cmd.ClientIP.String()
if len(clientIP) == 0 { if len(cmd.ClientIP) == 0 {
clientIPStr = "" clientIPStr = ""
} }
userAuthToken := userAuthToken{ userAuthToken := userAuthToken{
UserId: user.ID, UserId: cmd.User.ID,
AuthToken: hashedToken, AuthToken: hashedToken,
PrevAuthToken: hashedToken, PrevAuthToken: hashedToken,
ClientIp: clientIPStr, ClientIp: clientIPStr,
UserAgent: userAgent, UserAgent: cmd.UserAgent,
RotatedAt: now, RotatedAt: now,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
@@ -90,11 +96,21 @@ func (s *UserAuthTokenService) CreateToken(ctx context.Context, user *user.User,
AuthTokenSeen: false, AuthTokenSeen: false,
} }
err = s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { err = s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
_, err = dbSession.Insert(&userAuthToken) if cmd.ExternalSession != nil {
return err inErr := s.externalSessionStore.Create(ctx, cmd.ExternalSession)
}) if inErr != nil {
return inErr
}
userAuthToken.ExternalSessionId = cmd.ExternalSession.ID
}
inErr := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
_, err := dbSession.Insert(&userAuthToken)
return err
})
return inErr
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -164,7 +180,6 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
return err return err
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -190,7 +205,6 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
return err return err
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -210,6 +224,38 @@ func (s *UserAuthTokenService) LookupToken(ctx context.Context, unhashedToken st
return &userToken, err return &userToken, err
} }
func (s *UserAuthTokenService) GetTokenByExternalSessionID(ctx context.Context, externalSessionID int64) (*auth.UserToken, error) {
var token userAuthToken
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
exists, err := dbSession.Where("external_session_id = ?", externalSessionID).Get(&token)
if err != nil {
return err
}
if !exists {
return auth.ErrUserTokenNotFound
}
return nil
})
if err != nil {
return nil, err
}
var userToken auth.UserToken
err = token.toUserToken(&userToken)
return &userToken, err
}
func (s *UserAuthTokenService) GetExternalSession(ctx context.Context, extSessionID int64) (*auth.ExternalSession, error) {
return s.externalSessionStore.Get(ctx, extSessionID)
}
func (s *UserAuthTokenService) FindExternalSessions(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
return s.externalSessionStore.List(ctx, query)
}
func (s *UserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) { func (s *UserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) {
if cmd.UnHashedToken == "" { if cmd.UnHashedToken == "" {
return nil, auth.ErrInvalidSessionToken return nil, auth.ErrInvalidSessionToken
@@ -277,7 +323,6 @@ func (s *UserAuthTokenService) rotateToken(ctx context.Context, token *auth.User
affected, err = res.RowsAffected() affected, err = res.RowsAffected()
return err return err
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -305,6 +350,8 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.User
return err return err
} }
ctxLogger := s.log.FromContext(ctx)
var rowsAffected int64 var rowsAffected int64
if soft { if soft {
@@ -324,7 +371,13 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.User
return err return err
} }
ctxLogger := s.log.FromContext(ctx) if model.ExternalSessionId != 0 {
err = s.externalSessionStore.Delete(ctx, model.ExternalSessionId)
if err != nil {
// Intentionally not returning error here, as the token has been revoked -> the backround job will clean up orphaned external sessions
ctxLogger.Warn("Failed to delete external session", "externalSessionID", model.ExternalSessionId, "err", err)
}
}
if rowsAffected == 0 { if rowsAffected == 0 {
ctxLogger.Debug("User auth token not found/revoked", "tokenID", model.Id, "userID", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent) ctxLogger.Debug("User auth token not found/revoked", "tokenID", model.Id, "userID", model.UserId, "clientIP", model.ClientIp, "userAgent", model.UserAgent)
@@ -337,51 +390,75 @@ func (s *UserAuthTokenService) RevokeToken(ctx context.Context, token *auth.User
} }
func (s *UserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId int64) error { func (s *UserAuthTokenService) RevokeAllUserTokens(ctx context.Context, userId int64) error {
return s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error { return s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
sql := `DELETE from user_auth_token WHERE user_id = ?` ctxLogger := s.log.FromContext(ctx)
res, err := dbSession.Exec(sql, userId) err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
sql := `DELETE from user_auth_token WHERE user_id = ?`
res, err := dbSession.Exec(sql, userId)
if err != nil {
return err
}
affected, err := res.RowsAffected()
if err != nil {
return err
}
ctxLogger.Debug("All user tokens for user revoked", "userID", userId, "count", affected)
return nil
})
if err != nil { if err != nil {
return err return err
} }
affected, err := res.RowsAffected() err = s.externalSessionStore.DeleteExternalSessionsByUserID(ctx, userId)
if err != nil { if err != nil {
return err // Intentionally not returning error here, as the token has been revoked -> the backround job will clean up orphaned external sessions
ctxLogger.Warn("Failed to delete external sessions for user", "userID", userId, "err", err)
} }
return nil
s.log.FromContext(ctx).Debug("All user tokens for user revoked", "userID", userId, "count", affected)
return err
}) })
} }
func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, userIds []int64) error { func (s *UserAuthTokenService) BatchRevokeAllUserTokens(ctx context.Context, userIds []int64) error {
return s.sqlStore.WithTransactionalDbSession(ctx, func(dbSession *db.Session) error { return s.sqlStore.InTransaction(ctx, func(ctx context.Context) error {
ctxLogger := s.log.FromContext(ctx)
if len(userIds) == 0 { if len(userIds) == 0 {
return nil return nil
} }
user_id_params := strings.Repeat(",?", len(userIds)-1) userIdParams := strings.Repeat(",?", len(userIds)-1)
sql := "DELETE from user_auth_token WHERE user_id IN (?" + user_id_params + ")" sql := "DELETE from user_auth_token WHERE user_id IN (?" + userIdParams + ")"
params := []any{sql} params := []any{sql}
for _, v := range userIds { for _, v := range userIds {
params = append(params, v) params = append(params, v)
} }
res, err := dbSession.Exec(params...) var affected int64
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
res, inErr := dbSession.Exec(params...)
if inErr != nil {
return inErr
}
affected, inErr = res.RowsAffected()
return inErr
})
if err != nil { if err != nil {
return err return err
} }
affected, err := res.RowsAffected() err = s.externalSessionStore.BatchDeleteExternalSessionsByUserIDs(ctx, userIds)
if err != nil { if err != nil {
return err ctxLogger.Warn("Failed to delete external sessions for users", "users", userIds, "err", err)
} }
s.log.FromContext(ctx).Debug("All user tokens for given users revoked", "usersCount", len(userIds), "count", affected) ctxLogger.Debug("All user tokens for given users revoked", "usersCount", len(userIds), "count", affected)
return err return nil
}) })
} }
+306 -52
View File
@@ -3,20 +3,25 @@ package authimpl
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net" "net"
"reflect" "reflect"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/tests/testsuite"
@@ -36,8 +41,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("When creating token", func(t *testing.T) { t.Run("When creating token", func(t *testing.T) {
createToken := func() *auth.UserToken { createToken := func() *auth.UserToken {
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr, userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
net.ParseIP("192.168.10.11"), "some user agent") User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err) require.Nil(t, err)
require.NotNil(t, userToken) require.NotNil(t, userToken)
require.False(t, userToken.AuthTokenSeen) require.False(t, userToken.AuthTokenSeen)
@@ -109,8 +117,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
userToken = createToken() userToken = createToken()
t.Run("When creating an additional token", func(t *testing.T) { t.Run("When creating an additional token", func(t *testing.T) {
userToken2, err := ctx.tokenService.CreateToken(context.Background(), usr, userToken2, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
net.ParseIP("192.168.10.11"), "some user agent") User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err) require.Nil(t, err)
require.NotNil(t, userToken2) require.NotNil(t, userToken2)
@@ -156,8 +167,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
for i := 0; i < 3; i++ { for i := 0; i < 3; i++ {
userId := usr.ID + int64(i+1) userId := usr.ID + int64(i+1)
userIds = append(userIds, userId) userIds = append(userIds, userId)
_, err := ctx.tokenService.CreateToken(context.Background(), usr, _, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
net.ParseIP("192.168.10.11"), "some user agent") User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err) require.Nil(t, err)
} }
@@ -173,10 +187,89 @@ func TestIntegrationUserAuthToken(t *testing.T) {
}) })
}) })
t.Run("When creating token with external session", func(t *testing.T) {
createToken := func() *auth.UserToken {
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: usr.ID, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
require.NotNil(t, userToken)
require.False(t, userToken.AuthTokenSeen)
return userToken
}
userToken := createToken()
t.Run("soft revoking existing token should remove the associated external session", func(t *testing.T) {
err := ctx.tokenService.RevokeToken(context.Background(), userToken, true)
require.Nil(t, err)
model, err := ctx.getAuthTokenByID(userToken.Id)
require.Nil(t, err)
require.NotNil(t, model)
require.Greater(t, model.RevokedAt, int64(0))
extSess, err := ctx.getExternalSessionByID(userToken.ExternalSessionId)
require.Nil(t, err)
require.Nil(t, extSess)
})
t.Run("revoking existing token should also remove the associated external session", func(t *testing.T) {
err := ctx.tokenService.RevokeToken(context.Background(), userToken, false)
require.Nil(t, err)
model, err := ctx.getAuthTokenByID(userToken.Id)
require.Nil(t, err)
require.Nil(t, model)
extSess, err := ctx.getExternalSessionByID(userToken.ExternalSessionId)
require.Nil(t, err)
require.Nil(t, extSess)
})
t.Run("When revoking users tokens in a batch", func(t *testing.T) {
t.Run("Can revoke all users tokens and associated external sessions", func(t *testing.T) {
userIds := []int64{}
extSessionIds := []int64{}
for i := 0; i < 3; i++ {
userId := usr.ID + int64(i+1)
userIds = append(userIds, userId)
token, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: userId, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
extSessionIds = append(extSessionIds, token.ExternalSessionId)
}
err := ctx.tokenService.BatchRevokeAllUserTokens(context.Background(), userIds)
require.Nil(t, err)
for i := 0; i < len(userIds); i++ {
tokens, err := ctx.tokenService.GetUserTokens(context.Background(), userIds[i])
require.Nil(t, err)
require.Equal(t, 0, len(tokens))
extSess, err := ctx.getExternalSessionByID(extSessionIds[i])
require.Nil(t, err)
require.Nil(t, extSess)
}
})
})
})
t.Run("expires correctly", func(t *testing.T) { t.Run("expires correctly", func(t *testing.T) {
ctx := createTestContext(t) ctx := createTestContext(t)
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr, userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
net.ParseIP("192.168.10.11"), "some user agent") User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err) require.Nil(t, err)
userToken, err = ctx.tokenService.LookupToken(context.Background(), userToken.UnhashedToken) userToken, err = ctx.tokenService.LookupToken(context.Background(), userToken.UnhashedToken)
@@ -262,7 +355,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("can properly rotate tokens", func(t *testing.T) { t.Run("can properly rotate tokens", func(t *testing.T) {
getTime = func() time.Time { return now } getTime = func() time.Time { return now }
ctx := createTestContext(t) ctx := createTestContext(t)
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr, net.ParseIP("192.168.10.11"), "some user agent") userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err) require.Nil(t, err)
prevToken := userToken.AuthToken prevToken := userToken.AuthToken
@@ -335,8 +432,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("keeps prev token valid for 1 minute after it is confirmed", func(t *testing.T) { t.Run("keeps prev token valid for 1 minute after it is confirmed", func(t *testing.T) {
getTime = func() time.Time { return now } getTime = func() time.Time { return now }
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr, userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
net.ParseIP("192.168.10.11"), "some user agent") User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err) require.Nil(t, err)
require.NotNil(t, userToken) require.NotNil(t, userToken)
@@ -368,8 +468,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
}) })
t.Run("will not mark token unseen when prev and current are the same", func(t *testing.T) { t.Run("will not mark token unseen when prev and current are the same", func(t *testing.T) {
userToken, err := ctx.tokenService.CreateToken(context.Background(), usr, userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
net.ParseIP("192.168.10.11"), "some user agent") User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err) require.Nil(t, err)
require.NotNil(t, userToken) require.NotNil(t, userToken)
@@ -389,7 +492,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("RotateToken", func(t *testing.T) { t.Run("RotateToken", func(t *testing.T) {
var prev string var prev string
token, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "") token, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err) require.NoError(t, err)
t.Run("should rotate token when called with current auth token", func(t *testing.T) { t.Run("should rotate token when called with current auth token", func(t *testing.T) {
prev = token.UnhashedToken prev = token.UnhashedToken
@@ -412,7 +519,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
}) })
t.Run("should return error when token is revoked", func(t *testing.T) { t.Run("should return error when token is revoked", func(t *testing.T) {
revokedToken, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "") revokedToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err) require.NoError(t, err)
// mark token as revoked // mark token as revoked
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error { err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
@@ -426,7 +537,11 @@ func TestIntegrationUserAuthToken(t *testing.T) {
}) })
t.Run("should return error when token has expired", func(t *testing.T) { t.Run("should return error when token has expired", func(t *testing.T) {
expiredToken, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "") expiredToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err) require.NoError(t, err)
// mark token as expired // mark token as expired
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error { err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
@@ -441,10 +556,18 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("should only delete revoked tokens that are outside on specified window", func(t *testing.T) { t.Run("should only delete revoked tokens that are outside on specified window", func(t *testing.T) {
usr := &user.User{ID: 100} usr := &user.User{ID: 100}
token1, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "") token1, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err) require.NoError(t, err)
token2, err := ctx.tokenService.CreateToken(context.Background(), usr, nil, "") token2, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: nil,
UserAgent: "",
})
require.NoError(t, err) require.NoError(t, err)
getTime = func() time.Time { getTime = func() time.Time {
@@ -474,18 +597,19 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("When populating userAuthToken from UserToken should copy all properties", func(t *testing.T) { t.Run("When populating userAuthToken from UserToken should copy all properties", func(t *testing.T) {
ut := auth.UserToken{ ut := auth.UserToken{
Id: 1, Id: 1,
UserId: 2, UserId: 2,
AuthToken: "a", AuthToken: "a",
PrevAuthToken: "b", PrevAuthToken: "b",
UserAgent: "c", UserAgent: "c",
ClientIp: "d", ClientIp: "d",
AuthTokenSeen: true, AuthTokenSeen: true,
SeenAt: 3, SeenAt: 3,
RotatedAt: 4, RotatedAt: 4,
CreatedAt: 5, CreatedAt: 5,
UpdatedAt: 6, UpdatedAt: 6,
UnhashedToken: "e", UnhashedToken: "e",
ExternalSessionId: 7,
} }
utBytes, err := json.Marshal(ut) utBytes, err := json.Marshal(ut)
require.Nil(t, err) require.Nil(t, err)
@@ -507,18 +631,19 @@ func TestIntegrationUserAuthToken(t *testing.T) {
t.Run("When populating userToken from userAuthToken should copy all properties", func(t *testing.T) { t.Run("When populating userToken from userAuthToken should copy all properties", func(t *testing.T) {
uat := userAuthToken{ uat := userAuthToken{
Id: 1, Id: 1,
UserId: 2, UserId: 2,
AuthToken: "a", AuthToken: "a",
PrevAuthToken: "b", PrevAuthToken: "b",
UserAgent: "c", UserAgent: "c",
ClientIp: "d", ClientIp: "d",
AuthTokenSeen: true, AuthTokenSeen: true,
SeenAt: 3, SeenAt: 3,
RotatedAt: 4, RotatedAt: 4,
CreatedAt: 5, CreatedAt: 5,
UpdatedAt: 6, UpdatedAt: 6,
UnhashedToken: "e", UnhashedToken: "e",
ExternalSessionId: 7,
} }
uatBytes, err := json.Marshal(uat) uatBytes, err := json.Marshal(uat)
require.Nil(t, err) require.Nil(t, err)
@@ -551,22 +676,27 @@ func createTestContext(t *testing.T) *testContext {
TokenRotationIntervalMinutes: 10, TokenRotationIntervalMinutes: 10,
} }
extSessionStore := provideExternalSessionStore(sqlstore, &fakes.FakeSecretsService{}, tracing.InitializeTracerForTest())
tokenService := &UserAuthTokenService{ tokenService := &UserAuthTokenService{
sqlStore: sqlstore, sqlStore: sqlstore,
cfg: cfg, cfg: cfg,
log: log.New("test-logger"), log: log.New("test-logger"),
singleflight: new(singleflight.Group), singleflight: new(singleflight.Group),
externalSessionStore: extSessionStore,
} }
return &testContext{ return &testContext{
sqlstore: sqlstore, sqlstore: sqlstore,
tokenService: tokenService, tokenService: tokenService,
extSessionStore: &extSessionStore,
} }
} }
type testContext struct { type testContext struct {
sqlstore db.DB sqlstore db.DB
tokenService *UserAuthTokenService tokenService *UserAuthTokenService
extSessionStore *auth.ExternalSessionStore
} }
func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) { func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
@@ -585,6 +715,22 @@ func (c *testContext) getAuthTokenByID(id int64) (*userAuthToken, error) {
return res, err return res, err
} }
func (c *testContext) getExternalSessionByID(ID int64) (*auth.ExternalSession, error) {
var res *auth.ExternalSession
err := c.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
var t auth.ExternalSession
found, err := sess.ID(ID).Get(&t)
if err != nil || !found {
return err
}
res = &t
return nil
})
return res, err
}
func (c *testContext) updateRotatedAt(id, rotatedAt int64) (bool, error) { func (c *testContext) updateRotatedAt(id, rotatedAt int64) (bool, error) {
hasRowsAffected := false hasRowsAffected := false
err := c.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error { err := c.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
@@ -609,8 +755,11 @@ func TestIntegrationTokenCount(t *testing.T) {
user := &user.User{ID: int64(10)} user := &user.User{ID: int64(10)}
createToken := func() *auth.UserToken { createToken := func() *auth.UserToken {
userToken, err := ctx.tokenService.CreateToken(context.Background(), user, userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
net.ParseIP("192.168.10.11"), "some user agent") User: user,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
})
require.Nil(t, err) require.Nil(t, err)
require.NotNil(t, userToken) require.NotNil(t, userToken)
require.False(t, userToken.AuthTokenSeen) require.False(t, userToken.AuthTokenSeen)
@@ -637,3 +786,108 @@ func TestIntegrationTokenCount(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, int64(0), count) require.Equal(t, int64(0), count)
} }
func TestRevokeAllUserTokens(t *testing.T) {
t.Run("should not fail if the external sessions could not be removed", func(t *testing.T) {
ctx := createTestContext(t)
usr := &user.User{ID: int64(10)}
// Mock the external session store to return an error
mockExternalSessionStore := &authtest.MockExternalSessionStore{}
mockExternalSessionStore.On("Create", mock.Anything, mock.IsType(&auth.ExternalSession{})).Run(func(args mock.Arguments) {
extSession := args.Get(1).(*auth.ExternalSession)
extSession.ID = 1
}).Return(nil)
mockExternalSessionStore.On("DeleteExternalSessionsByUserID", mock.Anything, usr.ID).Return(errors.New("some error"))
ctx.tokenService.externalSessionStore = mockExternalSessionStore
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: usr.ID, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
require.NotNil(t, userToken)
err = ctx.tokenService.RevokeAllUserTokens(context.Background(), usr.ID)
require.Nil(t, err)
model, err := ctx.getAuthTokenByID(userToken.Id)
require.Nil(t, err)
require.Nil(t, model)
})
}
func TestRevokeToken(t *testing.T) {
t.Run("should not fail if the external sessions could not be removed", func(t *testing.T) {
ctx := createTestContext(t)
usr := &user.User{ID: int64(10)}
mockExternalSessionStore := &authtest.MockExternalSessionStore{}
mockExternalSessionStore.On("Create", mock.Anything, mock.IsType(&auth.ExternalSession{})).Run(func(args mock.Arguments) {
extSession := args.Get(1).(*auth.ExternalSession)
extSession.ID = 2
}).Return(nil)
mockExternalSessionStore.On("Delete", mock.Anything, int64(2)).Return(errors.New("some error"))
ctx.tokenService.externalSessionStore = mockExternalSessionStore
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: usr.ID, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
require.NotNil(t, userToken)
err = ctx.tokenService.RevokeToken(context.Background(), userToken, false)
require.Nil(t, err)
model, err := ctx.getAuthTokenByID(userToken.Id)
require.Nil(t, err)
require.Nil(t, model)
})
}
func TestBatchRevokeAllUserTokens(t *testing.T) {
t.Run("should not fail if the external sessions could not be removed", func(t *testing.T) {
ctx := createTestContext(t)
userIds := []int64{1, 2, 3}
mockExternalSessionStore := &authtest.MockExternalSessionStore{}
mockExternalSessionStore.On("BatchDeleteExternalSessionsByUserIDs", mock.Anything, userIds).Return(errors.New("some error"))
ctr := int64(0)
mockExternalSessionStore.On("Create", mock.Anything, mock.IsType(&auth.ExternalSession{})).Run(func(args mock.Arguments) {
extSession := args.Get(1).(*auth.ExternalSession)
ctr += 1
extSession.ID = ctr
}).Return(nil)
ctx.tokenService.externalSessionStore = mockExternalSessionStore
for _, userID := range userIds {
usr := &user.User{ID: userID}
userToken, err := ctx.tokenService.CreateToken(context.Background(), &auth.CreateTokenCommand{
User: usr,
ClientIP: net.ParseIP("192.168.10.11"),
UserAgent: "some user agent",
ExternalSession: &auth.ExternalSession{UserID: usr.ID, AuthModule: "test", UserAuthID: 1},
})
require.Nil(t, err)
require.NotNil(t, userToken)
}
// Batch revoke all user tokens
err := ctx.tokenService.BatchRevokeAllUserTokens(context.Background(), userIds)
require.Nil(t, err)
// Verify that the tokens have been revoked
for _, userID := range userIds {
tokens, err := ctx.tokenService.GetUserTokens(context.Background(), userID)
require.Nil(t, err)
require.Equal(t, 0, len(tokens))
}
})
}
@@ -0,0 +1,244 @@
package authimpl
import (
"context"
"crypto/sha256"
"encoding/base64"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/secrets"
)
var _ auth.ExternalSessionStore = (*store)(nil)
type store struct {
sqlStore db.DB
secretsService secrets.Service
tracer tracing.Tracer
}
func provideExternalSessionStore(sqlStore db.DB, secretService secrets.Service, tracer tracing.Tracer) auth.ExternalSessionStore {
return &store{
sqlStore: sqlStore,
secretsService: secretService,
tracer: tracer,
}
}
func (s *store) Get(ctx context.Context, extSessionID int64) (*auth.ExternalSession, error) {
ctx, span := s.tracer.Start(ctx, "externalsession.Get")
defer span.End()
externalSession := &auth.ExternalSession{ID: extSessionID}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
found, err := sess.Get(externalSession)
if err != nil {
return err
}
if !found {
return auth.ErrExternalSessionNotFound
}
return nil
})
if err != nil {
return nil, err
}
err = s.decryptSecrets(externalSession)
if err != nil {
return nil, err
}
return externalSession, nil
}
func (s *store) List(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
ctx, span := s.tracer.Start(ctx, "externalsession.List")
defer span.End()
externalSession := &auth.ExternalSession{}
if query.ID != 0 {
externalSession.ID = query.ID
}
hash := sha256.New()
if query.SessionID != "" {
hash.Write([]byte(query.SessionID))
externalSession.SessionIDHash = base64.RawStdEncoding.EncodeToString(hash.Sum(nil))
}
if query.NameID != "" {
hash.Reset()
hash.Write([]byte(query.NameID))
externalSession.NameIDHash = base64.RawStdEncoding.EncodeToString(hash.Sum(nil))
}
queryResult := make([]*auth.ExternalSession, 0)
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
return sess.Find(&queryResult, externalSession)
})
if err != nil {
return nil, err
}
for _, extSession := range queryResult {
err := s.decryptSecrets(extSession)
if err != nil {
return nil, err
}
}
return queryResult, nil
}
func (s *store) Create(ctx context.Context, extSession *auth.ExternalSession) error {
ctx, span := s.tracer.Start(ctx, "externalsession.Create")
defer span.End()
var err error
clone := extSession.Clone()
clone.AccessToken, err = s.encryptAndEncode(extSession.AccessToken)
if err != nil {
return err
}
clone.RefreshToken, err = s.encryptAndEncode(extSession.RefreshToken)
if err != nil {
return err
}
clone.IDToken, err = s.encryptAndEncode(extSession.IDToken)
if err != nil {
return err
}
if extSession.NameID != "" {
hash := sha256.New()
hash.Write([]byte(extSession.NameID))
clone.NameIDHash = base64.RawStdEncoding.EncodeToString(hash.Sum(nil))
}
clone.NameID, err = s.encryptAndEncode(extSession.NameID)
if err != nil {
return err
}
if extSession.SessionID != "" {
hash := sha256.New()
hash.Write([]byte(extSession.SessionID))
clone.SessionIDHash = base64.RawStdEncoding.EncodeToString(hash.Sum(nil))
}
clone.SessionID, err = s.encryptAndEncode(extSession.SessionID)
if err != nil {
return err
}
err = s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Insert(clone)
return err
})
if err != nil {
return err
}
extSession.ID = clone.ID
return nil
}
func (s *store) Delete(ctx context.Context, ID int64) error {
ctx, span := s.tracer.Start(ctx, "externalsession.Delete")
defer span.End()
externalSession := &auth.ExternalSession{ID: ID}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Delete(externalSession)
return err
})
return err
}
func (s *store) DeleteExternalSessionsByUserID(ctx context.Context, userID int64) error {
ctx, span := s.tracer.Start(ctx, "externalsession.DeleteExternalSessionsByUserID")
defer span.End()
externalSession := &auth.ExternalSession{UserID: userID}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Delete(externalSession)
return err
})
return err
}
func (s *store) BatchDeleteExternalSessionsByUserIDs(ctx context.Context, userIDs []int64) error {
ctx, span := s.tracer.Start(ctx, "externalsession.BatchDeleteExternalSessionsByUserIDs")
defer span.End()
externalSession := &auth.ExternalSession{}
err := s.sqlStore.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.In("user_id", userIDs).Delete(externalSession)
return err
})
return err
}
func (s *store) decryptSecrets(extSession *auth.ExternalSession) error {
var err error
extSession.AccessToken, err = s.decodeAndDecrypt(extSession.AccessToken)
if err != nil {
return err
}
extSession.RefreshToken, err = s.decodeAndDecrypt(extSession.RefreshToken)
if err != nil {
return err
}
extSession.IDToken, err = s.decodeAndDecrypt(extSession.IDToken)
if err != nil {
return err
}
extSession.NameID, err = s.decodeAndDecrypt(extSession.NameID)
if err != nil {
return err
}
extSession.SessionID, err = s.decodeAndDecrypt(extSession.SessionID)
if err != nil {
return err
}
return nil
}
func (s *store) encryptAndEncode(str string) (string, error) {
if str == "" {
return "", nil
}
encrypted, err := s.secretsService.Encrypt(context.Background(), []byte(str), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}
func (s *store) decodeAndDecrypt(str string) (string, error) {
// Bail out if empty string since it'll cause a segfault in Decrypt
if str == "" {
return "", nil
}
decoded, err := base64.StdEncoding.DecodeString(str)
if err != nil {
return "", err
}
decrypted, err := s.secretsService.Decrypt(context.Background(), decoded)
if err != nil {
return "", err
}
return string(decrypted), nil
}
@@ -0,0 +1,228 @@
package authimpl
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/stretchr/testify/require"
)
func TestGetExternalSession(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("returns existing external session", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
AccessToken: "access-token",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
actual, err := store.Get(context.Background(), extSession.ID)
require.NoError(t, err)
require.EqualValues(t, extSession.ID, actual.ID)
require.EqualValues(t, extSession.AccessToken, actual.AccessToken)
})
t.Run("returns not found if the external session is missing", func(t *testing.T) {
store := setupTest(t)
_, err := store.Get(context.Background(), 999)
require.ErrorIs(t, err, auth.ErrExternalSessionNotFound)
})
}
func TestFindExternalSessions(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("returns external sessions by ID", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
AccessToken: "access-token",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{ID: extSession.ID}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 1)
require.EqualValues(t, extSession.ID, actual[0].ID)
require.EqualValues(t, extSession.AccessToken, actual[0].AccessToken)
})
t.Run("returns external sessions by SessionID", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
SessionID: "session-index",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{SessionID: extSession.SessionID}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 1)
require.EqualValues(t, extSession.ID, actual[0].ID)
require.EqualValues(t, extSession.SessionID, actual[0].SessionID)
})
t.Run("returns external sessions by NameID", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
NameID: "name-id",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{NameID: extSession.NameID}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 1)
require.EqualValues(t, extSession.ID, actual[0].ID)
require.EqualValues(t, extSession.NameID, actual[0].NameID)
})
t.Run("returns empty result if no external sessions match the query", func(t *testing.T) {
store := setupTest(t)
query := &auth.ListExternalSessionQuery{ID: 999}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 0)
})
}
func TestDeleteExternalSessionsByUserID(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("deletes all external sessions for a given user ID", func(t *testing.T) {
store := setupTest(t)
userID := int64(1)
extSession1 := &auth.ExternalSession{
UserID: userID,
AccessToken: "access-token-1",
}
extSession2 := &auth.ExternalSession{
UserID: userID,
AccessToken: "access-token-2",
}
err := store.Create(context.Background(), extSession1)
require.NoError(t, err)
err = store.Create(context.Background(), extSession2)
require.NoError(t, err)
err = store.DeleteExternalSessionsByUserID(context.Background(), userID)
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 0)
})
t.Run("returns no error if no external sessions exist for the given user ID", func(t *testing.T) {
store := setupTest(t)
userID := int64(999)
err := store.DeleteExternalSessionsByUserID(context.Background(), userID)
require.NoError(t, err)
})
}
func TestDeleteExternalSession(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("deletes an existing external session", func(t *testing.T) {
store := setupTest(t)
extSession := &auth.ExternalSession{
AccessToken: "access-token",
}
err := store.Create(context.Background(), extSession)
require.NoError(t, err)
err = store.Delete(context.Background(), extSession.ID)
require.NoError(t, err)
_, err = store.Get(context.Background(), extSession.ID)
require.ErrorIs(t, err, auth.ErrExternalSessionNotFound)
})
t.Run("returns no error if the external session does not exist", func(t *testing.T) {
store := setupTest(t)
err := store.Delete(context.Background(), 999)
require.NoError(t, err)
})
}
func TestBatchDeleteExternalSessionsByUserIDs(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
t.Run("deletes all external sessions for given user IDs", func(t *testing.T) {
store := setupTest(t)
userID1 := int64(1)
userID2 := int64(2)
extSession1 := &auth.ExternalSession{
UserID: userID1,
AccessToken: "access-token-1",
}
extSession2 := &auth.ExternalSession{
UserID: userID2,
AccessToken: "access-token-2",
}
err := store.Create(context.Background(), extSession1)
require.NoError(t, err)
err = store.Create(context.Background(), extSession2)
require.NoError(t, err)
err = store.BatchDeleteExternalSessionsByUserIDs(context.Background(), []int64{userID1, userID2})
require.NoError(t, err)
query := &auth.ListExternalSessionQuery{}
actual, err := store.List(context.Background(), query)
require.NoError(t, err)
require.Len(t, actual, 0)
})
t.Run("returns no error if no external sessions exist for the given user IDs", func(t *testing.T) {
store := setupTest(t)
err := store.BatchDeleteExternalSessionsByUserIDs(context.Background(), []int64{999, 1000})
require.NoError(t, err)
})
}
func setupTest(t *testing.T) *store {
sqlStore := db.InitTestDB(t)
secretService := fakes.NewFakeSecretsService()
tracer := tracing.InitializeTracerForTest()
externalSessionStore := provideExternalSessionStore(sqlStore, secretService, tracer).(*store)
return externalSessionStore
}
+16 -13
View File
@@ -7,19 +7,20 @@ import (
) )
type userAuthToken struct { type userAuthToken struct {
Id int64 Id int64
UserId int64 UserId int64
AuthToken string AuthToken string
PrevAuthToken string PrevAuthToken string
UserAgent string UserAgent string
ClientIp string ClientIp string
AuthTokenSeen bool AuthTokenSeen bool
SeenAt int64 SeenAt int64
RotatedAt int64 RotatedAt int64
CreatedAt int64 CreatedAt int64
UpdatedAt int64 UpdatedAt int64
RevokedAt int64 RevokedAt int64
UnhashedToken string `xorm:"-"` UnhashedToken string `xorm:"-"`
ExternalSessionId int64
} }
func userAuthTokenFromUserToken(ut *auth.UserToken) (*userAuthToken, error) { func userAuthTokenFromUserToken(ut *auth.UserToken) (*userAuthToken, error) {
@@ -46,6 +47,7 @@ func (uat *userAuthToken) fromUserToken(ut *auth.UserToken) error {
uat.UpdatedAt = ut.UpdatedAt uat.UpdatedAt = ut.UpdatedAt
uat.RevokedAt = ut.RevokedAt uat.RevokedAt = ut.RevokedAt
uat.UnhashedToken = ut.UnhashedToken uat.UnhashedToken = ut.UnhashedToken
uat.ExternalSessionId = ut.ExternalSessionId
return nil return nil
} }
@@ -68,5 +70,6 @@ func (uat *userAuthToken) toUserToken(ut *auth.UserToken) error {
ut.UpdatedAt = uat.UpdatedAt ut.UpdatedAt = uat.UpdatedAt
ut.RevokedAt = uat.RevokedAt ut.RevokedAt = uat.RevokedAt
ut.UnhashedToken = uat.UnhashedToken ut.UnhashedToken = uat.UnhashedToken
ut.ExternalSessionId = uat.ExternalSessionId
return nil return nil
} }
@@ -16,6 +16,9 @@ func (s *UserAuthTokenService) Run(ctx context.Context) error {
if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil { if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil {
s.log.Error("An error occurred while deleting expired tokens", "err", err) s.log.Error("An error occurred while deleting expired tokens", "err", err)
} }
if err := s.deleteOrphanedExternalSessions(ctx); err != nil {
s.log.Error("An error occurred while deleting orphaned external sessions", "err", err)
}
}) })
if err != nil { if err != nil {
s.log.Error("Failed to lock and execute cleanup of expired auth token", "error", err) s.log.Error("Failed to lock and execute cleanup of expired auth token", "error", err)
@@ -28,6 +31,9 @@ func (s *UserAuthTokenService) Run(ctx context.Context) error {
if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil { if _, err := s.deleteExpiredTokens(ctx, maxInactiveLifetime, maxLifetime); err != nil {
s.log.Error("An error occurred while deleting expired tokens", "err", err) s.log.Error("An error occurred while deleting expired tokens", "err", err)
} }
if err := s.deleteOrphanedExternalSessions(ctx); err != nil {
s.log.Error("An error occurred while deleting orphaned external sessions", "err", err)
}
}) })
if err != nil { if err != nil {
s.log.Error("Failed to lock and execute cleanup of expired auth token", "error", err) s.log.Error("Failed to lock and execute cleanup of expired auth token", "error", err)
@@ -66,3 +72,29 @@ func (s *UserAuthTokenService) deleteExpiredTokens(ctx context.Context, maxInact
return affected, err return affected, err
} }
func (s *UserAuthTokenService) deleteOrphanedExternalSessions(ctx context.Context) error {
s.log.Debug("Starting cleanup of external sessions")
var affected int64
err := s.sqlStore.WithDbSession(ctx, func(dbSession *db.Session) error {
sql := `DELETE FROM user_external_session WHERE NOT EXISTS (SELECT 1 FROM user_auth_token WHERE user_external_session.id = user_auth_token.external_session_id)`
res, err := dbSession.Exec(sql)
if err != nil {
return err
}
affected, err = res.RowsAffected()
if err != nil {
s.log.Error("Failed to cleanup orphaned external sessions", "error", err)
return nil
}
s.log.Debug("Cleanup of orphaned external sessions done", "count", affected)
return nil
})
return err
}
@@ -9,9 +9,14 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/auth"
) )
func TestUserAuthTokenCleanup(t *testing.T) { func TestIntegrationUserAuthTokenCleanup(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
setup := func() *testContext { setup := func() *testContext {
ctx := createTestContext(t) ctx := createTestContext(t)
maxInactiveLifetime, _ := time.ParseDuration("168h") maxInactiveLifetime, _ := time.ParseDuration("168h")
@@ -75,3 +80,61 @@ func TestUserAuthTokenCleanup(t *testing.T) {
require.Equal(t, int64(3), affected) require.Equal(t, int64(3), affected)
}) })
} }
func TestIntegrationOrphanedExternalSessionsCleanup(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
setup := func() *testContext {
ctx := createTestContext(t)
return ctx
}
insertExternalSession := func(ctx *testContext, id int64) {
es := &auth.ExternalSession{ID: id, UserAuthID: 1, UserID: 1}
err := ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
_, err := sess.Insert(es)
require.Nil(t, err)
return nil
})
require.NoError(t, err)
}
insertAuthToken := func(ctx *testContext, token string, externalSessionId int64) {
ut := userAuthToken{AuthToken: token, PrevAuthToken: fmt.Sprintf("old%s", token), ExternalSessionId: externalSessionId}
err := ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
_, err := sess.Insert(&ut)
require.Nil(t, err)
return nil
})
require.NoError(t, err)
}
t.Run("should delete orphaned external sessions", func(t *testing.T) {
ctx := setup()
// insert three external sessions
for i := int64(1); i <= 3; i++ {
insertExternalSession(ctx, i)
}
// insert two auth tokens linked to external sessions
insertAuthToken(ctx, "token1", 1)
insertAuthToken(ctx, "token2", 2)
// delete orphaned external sessions
err := ctx.tokenService.deleteOrphanedExternalSessions(context.Background())
require.NoError(t, err)
// verify that only the orphaned external session is deleted
var count int64
err = ctx.sqlstore.WithDbSession(context.Background(), func(sess *db.Session) error {
count, err = sess.Count(&auth.ExternalSession{})
require.Nil(t, err)
return nil
})
require.NoError(t, err)
require.Equal(t, int64(2), count)
})
}
@@ -0,0 +1,162 @@
// Code generated by mockery v2.42.1. DO NOT EDIT.
package authtest
import (
context "context"
auth "github.com/grafana/grafana/pkg/services/auth"
mock "github.com/stretchr/testify/mock"
)
// MockExternalSessionStore is an autogenerated mock type for the ExternalSessionStore type
type MockExternalSessionStore struct {
mock.Mock
}
// BatchDeleteExternalSessionsByUserIDs provides a mock function with given fields: ctx, userIDs
func (_m *MockExternalSessionStore) BatchDeleteExternalSessionsByUserIDs(ctx context.Context, userIDs []int64) error {
ret := _m.Called(ctx, userIDs)
if len(ret) == 0 {
panic("no return value specified for BatchDeleteExternalSessionsByUserIDs")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, []int64) error); ok {
r0 = rf(ctx, userIDs)
} else {
r0 = ret.Error(0)
}
return r0
}
// Create provides a mock function with given fields: ctx, extSesion
func (_m *MockExternalSessionStore) Create(ctx context.Context, extSesion *auth.ExternalSession) error {
ret := _m.Called(ctx, extSesion)
if len(ret) == 0 {
panic("no return value specified for Create")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *auth.ExternalSession) error); ok {
r0 = rf(ctx, extSesion)
} else {
r0 = ret.Error(0)
}
return r0
}
// Delete provides a mock function with given fields: ctx, ID
func (_m *MockExternalSessionStore) Delete(ctx context.Context, ID int64) error {
ret := _m.Called(ctx, ID)
if len(ret) == 0 {
panic("no return value specified for Delete")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
r0 = rf(ctx, ID)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteExternalSessionsByUserID provides a mock function with given fields: ctx, userID
func (_m *MockExternalSessionStore) DeleteExternalSessionsByUserID(ctx context.Context, userID int64) error {
ret := _m.Called(ctx, userID)
if len(ret) == 0 {
panic("no return value specified for DeleteExternalSessionsByUserID")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
r0 = rf(ctx, userID)
} else {
r0 = ret.Error(0)
}
return r0
}
// Get provides a mock function with given fields: ctx, ID
func (_m *MockExternalSessionStore) Get(ctx context.Context, ID int64) (*auth.ExternalSession, error) {
ret := _m.Called(ctx, ID)
if len(ret) == 0 {
panic("no return value specified for Get")
}
var r0 *auth.ExternalSession
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, int64) (*auth.ExternalSession, error)); ok {
return rf(ctx, ID)
}
if rf, ok := ret.Get(0).(func(context.Context, int64) *auth.ExternalSession); ok {
r0 = rf(ctx, ID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*auth.ExternalSession)
}
}
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
r1 = rf(ctx, ID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// List provides a mock function with given fields: ctx, query
func (_m *MockExternalSessionStore) List(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
ret := _m.Called(ctx, query)
if len(ret) == 0 {
panic("no return value specified for List")
}
var r0 []*auth.ExternalSession
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error)); ok {
return rf(ctx, query)
}
if rf, ok := ret.Get(0).(func(context.Context, *auth.ListExternalSessionQuery) []*auth.ExternalSession); ok {
r0 = rf(ctx, query)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*auth.ExternalSession)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *auth.ListExternalSessionQuery) error); ok {
r1 = rf(ctx, query)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// NewMockExternalSessionStore creates a new instance of MockExternalSessionStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockExternalSessionStore(t interface {
mock.TestingT
Cleanup(func())
}) *MockExternalSessionStore {
mock := &MockExternalSessionStore{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
+29 -15
View File
@@ -11,26 +11,28 @@ import (
"github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/user"
) )
type FakeUserAuthTokenService struct { type FakeUserAuthTokenService struct {
CreateTokenProvider func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) CreateTokenProvider func(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error)
RotateTokenProvider func(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) RotateTokenProvider func(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error)
TryRotateTokenProvider func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, *auth.UserToken, error) GetTokenByExternalSessionIDProvider func(ctx context.Context, externalSessionID int64) (*auth.UserToken, error)
LookupTokenProvider func(ctx context.Context, unhashedToken string) (*auth.UserToken, error) GetExternalSessionProvider func(ctx context.Context, externalSessionID int64) (*auth.ExternalSession, error)
RevokeTokenProvider func(ctx context.Context, token *auth.UserToken, soft bool) error FindExternalSessionsProvider func(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error)
RevokeAllUserTokensProvider func(ctx context.Context, userID int64) error TryRotateTokenProvider func(ctx context.Context, token *auth.UserToken, clientIP net.IP, userAgent string) (bool, *auth.UserToken, error)
ActiveTokenCountProvider func(ctx context.Context, userID *int64) (int64, error) LookupTokenProvider func(ctx context.Context, unhashedToken string) (*auth.UserToken, error)
GetUserTokenProvider func(ctx context.Context, userID, userTokenID int64) (*auth.UserToken, error) RevokeTokenProvider func(ctx context.Context, token *auth.UserToken, soft bool) error
GetUserTokensProvider func(ctx context.Context, userID int64) ([]*auth.UserToken, error) RevokeAllUserTokensProvider func(ctx context.Context, userID int64) error
GetUserRevokedTokensProvider func(ctx context.Context, userID int64) ([]*auth.UserToken, error) ActiveTokenCountProvider func(ctx context.Context, userID *int64) (int64, error)
BatchRevokedTokenProvider func(ctx context.Context, userIDs []int64) error GetUserTokenProvider func(ctx context.Context, userID, userTokenID int64) (*auth.UserToken, error)
GetUserTokensProvider func(ctx context.Context, userID int64) ([]*auth.UserToken, error)
GetUserRevokedTokensProvider func(ctx context.Context, userID int64) ([]*auth.UserToken, error)
BatchRevokedTokenProvider func(ctx context.Context, userIDs []int64) error
} }
func NewFakeUserAuthTokenService() *FakeUserAuthTokenService { func NewFakeUserAuthTokenService() *FakeUserAuthTokenService {
return &FakeUserAuthTokenService{ return &FakeUserAuthTokenService{
CreateTokenProvider: func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { CreateTokenProvider: func(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error) {
return &auth.UserToken{ return &auth.UserToken{
UserId: 0, UserId: 0,
UnhashedToken: "", UnhashedToken: "",
@@ -72,14 +74,26 @@ func (s *FakeUserAuthTokenService) Init() error {
return nil return nil
} }
func (s *FakeUserAuthTokenService) CreateToken(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { func (s *FakeUserAuthTokenService) CreateToken(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error) {
return s.CreateTokenProvider(context.Background(), user, clientIP, userAgent) return s.CreateTokenProvider(context.Background(), cmd)
} }
func (s *FakeUserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) { func (s *FakeUserAuthTokenService) RotateToken(ctx context.Context, cmd auth.RotateCommand) (*auth.UserToken, error) {
return s.RotateTokenProvider(ctx, cmd) return s.RotateTokenProvider(ctx, cmd)
} }
func (s *FakeUserAuthTokenService) GetTokenByExternalSessionID(ctx context.Context, externalSessionID int64) (*auth.UserToken, error) {
return s.GetTokenByExternalSessionIDProvider(ctx, externalSessionID)
}
func (s *FakeUserAuthTokenService) GetExternalSession(ctx context.Context, externalSessionID int64) (*auth.ExternalSession, error) {
return s.GetExternalSessionProvider(ctx, externalSessionID)
}
func (s *FakeUserAuthTokenService) FindExternalSessions(ctx context.Context, query *auth.ListExternalSessionQuery) ([]*auth.ExternalSession, error) {
return s.FindExternalSessionsProvider(context.Background(), query)
}
func (s *FakeUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) { func (s *FakeUserAuthTokenService) LookupToken(ctx context.Context, unhashedToken string) (*auth.UserToken, error) {
return s.LookupTokenProvider(context.Background(), unhashedToken) return s.LookupTokenProvider(context.Background(), unhashedToken)
} }
+66
View File
@@ -0,0 +1,66 @@
package auth
import (
"context"
"time"
)
type ExternalSession struct {
ID int64 `xorm:"pk autoincr 'id'"`
UserID int64 `xorm:"user_id"`
UserAuthID int64 `xorm:"user_auth_id"`
AuthModule string `xorm:"auth_module"`
AccessToken string `xorm:"access_token"`
IDToken string `xorm:"id_token"`
RefreshToken string `xorm:"refresh_token"`
SessionID string `xorm:"session_id"`
SessionIDHash string `xorm:"session_id_hash"`
NameID string `xorm:"name_id"`
NameIDHash string `xorm:"name_id_hash"`
ExpiresAt time.Time `xorm:"expires_at"`
CreatedAt time.Time `xorm:"created 'created_at'"`
}
func (e *ExternalSession) TableName() string {
return "user_external_session"
}
func (e *ExternalSession) Clone() *ExternalSession {
return &ExternalSession{
ID: e.ID,
UserID: e.UserID,
UserAuthID: e.UserAuthID,
AuthModule: e.AuthModule,
AccessToken: e.AccessToken,
IDToken: e.IDToken,
RefreshToken: e.RefreshToken,
SessionID: e.SessionID,
SessionIDHash: e.SessionIDHash,
NameID: e.NameID,
NameIDHash: e.NameIDHash,
ExpiresAt: e.ExpiresAt,
CreatedAt: e.CreatedAt,
}
}
type ListExternalSessionQuery struct {
ID int64
NameID string
SessionID string
}
//go:generate mockery --name ExternalSessionStore --structname MockExternalSessionStore --outpkg authtest --filename external_session_store_mock.go --output ./authtest/
type ExternalSessionStore interface {
// Get returns the external session
Get(ctx context.Context, ID int64) (*ExternalSession, error)
// List returns all external sessions fπor the given query
List(ctx context.Context, query *ListExternalSessionQuery) ([]*ExternalSession, error)
// Create creates a new external session for a user
Create(ctx context.Context, extSesion *ExternalSession) error
// Delete deletes an external session
Delete(ctx context.Context, ID int64) error
// DeleteExternalSessionsByUserID deletes an external session
DeleteExternalSessionsByUserID(ctx context.Context, userID int64) error
// BatchDeleteExternalSessionsByUserIDs deletes external sessions by user IDs
BatchDeleteExternalSessionsByUserIDs(ctx context.Context, userIDs []int64) error
}
+29 -15
View File
@@ -32,9 +32,10 @@ const (
) )
const ( const (
MetaKeyUsername = "username" MetaKeyUsername = "username"
MetaKeyAuthModule = "authModule" MetaKeyAuthModule = "authModule"
MetaKeyIsLogin = "isLogin" MetaKeyIsLogin = "isLogin"
defaultRedirectToCookieKey = "redirect_to"
) )
// ClientParams are hints to the auth service about how to handle the identity management // ClientParams are hints to the auth service about how to handle the identity management
@@ -74,9 +75,11 @@ type FetchPermissionsParams struct {
Roles []string Roles []string
} }
type PostAuthHookFn func(ctx context.Context, identity *Identity, r *Request) error type (
type PostLoginHookFn func(ctx context.Context, identity *Identity, r *Request, err error) PostAuthHookFn func(ctx context.Context, identity *Identity, r *Request) error
type PreLogoutHookFn func(ctx context.Context, requester identity.Requester, sessionToken *usertoken.UserToken) error PostLoginHookFn func(ctx context.Context, identity *Identity, r *Request, err error)
PreLogoutHookFn func(ctx context.Context, requester identity.Requester, sessionToken *usertoken.UserToken) error
)
type Authenticator interface { type Authenticator interface {
// Authenticate authenticates a request // Authenticate authenticates a request
@@ -233,41 +236,52 @@ type RedirectValidator func(url string) error
// HandleLoginResponse is a utility function to perform common operations after a successful login and returns response.NormalResponse // HandleLoginResponse is a utility function to perform common operations after a successful login and returns response.NormalResponse
func HandleLoginResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) *response.NormalResponse { func HandleLoginResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) *response.NormalResponse {
result := map[string]any{"message": "Logged in"} result := map[string]any{"message": "Logged in"}
result["redirectUrl"] = handleLogin(r, w, cfg, identity, validator, features) result["redirectUrl"] = handleLogin(r, w, cfg, identity, validator, features, "")
return response.JSON(http.StatusOK, result) return response.JSON(http.StatusOK, result)
} }
// HandleLoginRedirect is a utility function to perform common operations after a successful login and redirects // HandleLoginRedirect is a utility function to perform common operations after a successful login and redirects
func HandleLoginRedirect(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) { func HandleLoginRedirect(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) {
redirectURL := handleLogin(r, w, cfg, identity, validator, features) redirectURL := handleLogin(r, w, cfg, identity, validator, features, "redirectTo")
http.Redirect(w, r, redirectURL, http.StatusFound) http.Redirect(w, r, redirectURL, http.StatusFound)
} }
// HandleLoginRedirectResponse is a utility function to perform common operations after a successful login and return a response.RedirectResponse // HandleLoginRedirectResponse is a utility function to perform common operations after a successful login and return a response.RedirectResponse
func HandleLoginRedirectResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) *response.RedirectResponse { func HandleLoginRedirectResponse(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles, redirectToCookieName string) *response.RedirectResponse {
return response.Redirect(handleLogin(r, w, cfg, identity, validator, features)) return response.Redirect(handleLogin(r, w, cfg, identity, validator, features, redirectToCookieName))
} }
func handleLogin(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles) string { func handleLogin(r *http.Request, w http.ResponseWriter, cfg *setting.Cfg, identity *Identity, validator RedirectValidator, features featuremgmt.FeatureToggles, redirectToCookieName string) string {
WriteSessionCookie(w, cfg, identity.SessionToken) WriteSessionCookie(w, cfg, identity.SessionToken)
redirectURL := cfg.AppSubURL + "/"
if features.IsEnabledGlobally(featuremgmt.FlagUseSessionStorageForRedirection) { if features.IsEnabledGlobally(featuremgmt.FlagUseSessionStorageForRedirection) {
return cfg.AppSubURL + "/" if redirectToCookieName != "" {
scopedRedirectToCookie, err := r.Cookie(redirectToCookieName)
if err == nil {
redirectTo, _ := url.QueryUnescape(scopedRedirectToCookie.Value)
if redirectTo != "" && validator(redirectTo) == nil {
redirectURL = cfg.AppSubURL + redirectTo
}
cookies.DeleteCookie(w, redirectToCookieName, cookieOptions(cfg))
}
}
return redirectURL
} }
redirectURL := cfg.AppSubURL + "/" redirectURL = cfg.AppSubURL + "/"
if redirectTo := getRedirectURL(r); len(redirectTo) > 0 { if redirectTo := getRedirectURL(r); len(redirectTo) > 0 {
if validator(redirectTo) == nil { if validator(redirectTo) == nil {
redirectURL = redirectTo redirectURL = redirectTo
} }
cookies.DeleteCookie(w, "redirect_to", cookieOptions(cfg)) cookies.DeleteCookie(w, defaultRedirectToCookieKey, cookieOptions(cfg))
} }
return redirectURL return redirectURL
} }
func getRedirectURL(r *http.Request) string { func getRedirectURL(r *http.Request) string {
cookie, err := r.Cookie("redirect_to") cookie, err := r.Cookie(defaultRedirectToCookieKey)
if err != nil { if err != nil {
return "" return ""
} }
+45 -6
View File
@@ -22,6 +22,7 @@ import (
"github.com/grafana/grafana/pkg/services/auth" "github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/clients" "github.com/grafana/grafana/pkg/services/authn/clients"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
@@ -52,8 +53,8 @@ func ProvideIdentitySynchronizer(s *Service) authn.IdentitySynchronizer {
} }
func ProvideService( func ProvideService(
cfg *setting.Cfg, tracer tracing.Tracer, cfg *setting.Cfg, tracer tracing.Tracer, sessionService auth.UserTokenService,
sessionService auth.UserTokenService, usageStats usagestats.Service, registerer prometheus.Registerer, usageStats usagestats.Service, registerer prometheus.Registerer, authTokenService login.AuthInfoService,
) *Service { ) *Service {
s := &Service{ s := &Service{
log: log.New("authn.service"), log: log.New("authn.service"),
@@ -64,6 +65,7 @@ func ProvideService(
tracer: tracer, tracer: tracer,
metrics: newMetrics(registerer), metrics: newMetrics(registerer),
sessionService: sessionService, sessionService: sessionService,
authTokenService: authTokenService,
preLogoutHooks: newQueue[authn.PreLogoutHookFn](), preLogoutHooks: newQueue[authn.PreLogoutHookFn](),
postAuthHooks: newQueue[authn.PostAuthHookFn](), postAuthHooks: newQueue[authn.PostAuthHookFn](),
postLoginHooks: newQueue[authn.PostLoginHookFn](), postLoginHooks: newQueue[authn.PostLoginHookFn](),
@@ -85,7 +87,8 @@ type Service struct {
tracer tracing.Tracer tracer tracing.Tracer
metrics *metrics metrics *metrics
sessionService auth.UserTokenService sessionService auth.UserTokenService
authTokenService login.AuthInfoService
// postAuthHooks are called after a successful authentication. They can modify the identity. // postAuthHooks are called after a successful authentication. They can modify the identity.
postAuthHooks *queue[authn.PostAuthHookFn] postAuthHooks *queue[authn.PostAuthHookFn]
@@ -238,7 +241,9 @@ func (s *Service) Login(ctx context.Context, client string, r *authn.Request) (i
s.log.FromContext(ctx).Debug("Failed to parse ip from address", "client", c.Name(), "id", id.ID, "addr", addr, "error", err) s.log.FromContext(ctx).Debug("Failed to parse ip from address", "client", c.Name(), "id", id.ID, "addr", addr, "error", err)
} }
sessionToken, err := s.sessionService.CreateToken(ctx, &user.User{ID: userID}, ip, r.HTTPRequest.UserAgent()) externalSession := s.resolveExternalSessionFromIdentity(ctx, id, userID)
sessionToken, err := s.sessionService.CreateToken(ctx, &auth.CreateTokenCommand{User: &user.User{ID: userID}, ClientIP: ip, UserAgent: r.HTTPRequest.UserAgent(), ExternalSession: externalSession})
if err != nil { if err != nil {
s.metrics.failedLogin.WithLabelValues(client).Inc() s.metrics.failedLogin.WithLabelValues(client).Inc()
s.log.FromContext(ctx).Error("Failed to create session", "client", client, "id", id.ID, "err", err) s.log.FromContext(ctx).Error("Failed to create session", "client", client, "id", id.ID, "err", err)
@@ -403,7 +408,8 @@ func (s *Service) resolveIdenity(ctx context.Context, orgID int64, typedID strin
AllowGlobalOrg: true, AllowGlobalOrg: true,
FetchSyncedUser: true, FetchSyncedUser: true,
SyncPermissions: true, SyncPermissions: true,
}}, nil },
}, nil
} }
if claims.IsIdentityType(t, claims.TypeServiceAccount) { if claims.IsIdentityType(t, claims.TypeServiceAccount) {
@@ -415,7 +421,8 @@ func (s *Service) resolveIdenity(ctx context.Context, orgID int64, typedID strin
AllowGlobalOrg: true, AllowGlobalOrg: true,
FetchSyncedUser: true, FetchSyncedUser: true,
SyncPermissions: true, SyncPermissions: true,
}}, nil },
}, nil
} }
resolver, ok := s.idenityResolverClients[string(t)] resolver, ok := s.idenityResolverClients[string(t)]
@@ -482,3 +489,35 @@ func orgIDFromHeader(req *http.Request) int64 {
} }
return id return id
} }
func (s *Service) resolveExternalSessionFromIdentity(ctx context.Context, identity *authn.Identity, userID int64) *auth.ExternalSession {
if identity.OAuthToken == nil {
return nil
}
info, err := s.authTokenService.GetAuthInfo(ctx, &login.GetAuthInfoQuery{AuthId: identity.GetAuthID(), UserId: userID})
if err != nil {
s.log.FromContext(ctx).Info("Failed to get auth info", "error", err, "authID", identity.GetAuthID(), "userID", userID)
return nil
}
extSession := &auth.ExternalSession{
AuthModule: identity.GetAuthenticatedBy(),
UserAuthID: info.Id,
UserID: userID,
}
extSession.AccessToken = identity.OAuthToken.AccessToken
extSession.RefreshToken = identity.OAuthToken.RefreshToken
extSession.ExpiresAt = identity.OAuthToken.Expiry
if idToken, ok := identity.OAuthToken.Extra("id_token").(string); ok && idToken != "" {
extSession.IDToken = idToken
}
// As of https://openid.net/specs/openid-connect-session-1_0.html
if sessionState, ok := identity.OAuthToken.Extra("session_state").(string); ok && sessionState != "" {
extSession.SessionID = sessionState
}
return extSession
}
+2 -4
View File
@@ -3,7 +3,6 @@ package authnimpl
import ( import (
"context" "context"
"errors" "errors"
"net"
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
@@ -24,7 +23,6 @@ import (
"github.com/grafana/grafana/pkg/services/auth/authtest" "github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/authn" "github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest" "github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
) )
@@ -399,11 +397,11 @@ func TestService_Login(t *testing.T) {
ExpectedIdentity: tt.expectedClientIdentity, ExpectedIdentity: tt.expectedClientIdentity,
}) })
svc.sessionService = &authtest.FakeUserAuthTokenService{ svc.sessionService = &authtest.FakeUserAuthTokenService{
CreateTokenProvider: func(ctx context.Context, user *user.User, clientIP net.IP, userAgent string) (*auth.UserToken, error) { CreateTokenProvider: func(ctx context.Context, cmd *auth.CreateTokenCommand) (*auth.UserToken, error) {
if tt.expectedSessionErr != nil { if tt.expectedSessionErr != nil {
return nil, tt.expectedSessionErr return nil, tt.expectedSessionErr
} }
return &auth.UserToken{UserId: user.ID}, nil return &auth.UserToken{UserId: cmd.User.ID}, nil
}, },
} }
}) })
+1 -1
View File
@@ -44,7 +44,7 @@ type Identity struct {
// IsGrafanaAdmin is true if the entity is a Grafana admin. // IsGrafanaAdmin is true if the entity is a Grafana admin.
IsGrafanaAdmin *bool IsGrafanaAdmin *bool
// AuthenticatedBy is the name of the authentication client that was used to authenticate the current Identity. // AuthenticatedBy is the name of the authentication client that was used to authenticate the current Identity.
// For example, "password", "apikey", "auth_ldap" or "auth_azuread". // For example, "password", "apikey", "ldap" or "oauth_azuread".
AuthenticatedBy string AuthenticatedBy string
// AuthId is the unique identifier for the entity in the external system. // AuthId is the unique identifier for the entity in the external system.
// Empty if the identity is provided by Grafana. // Empty if the identity is provided by Grafana.
+80 -5
View File
@@ -13,6 +13,8 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
"go.opentelemetry.io/otel/codes"
) )
type CloudMigrationAPI struct { type CloudMigrationAPI struct {
@@ -78,6 +80,9 @@ func (cma *CloudMigrationAPI) GetToken(c *contextmodel.ReqContext) response.Resp
token, err := cma.cloudMigrationService.GetToken(ctx) token, err := cma.cloudMigrationService.GetToken(ctx)
if err != nil { if err != nil {
span.SetStatus(codes.Error, "fetching cloud migration access token")
span.RecordError(err)
if !errors.Is(err, cloudmigration.ErrTokenNotFound) { if !errors.Is(err, cloudmigration.ErrTokenNotFound) {
logger.Error("fetching cloud migration access token", "err", err.Error()) logger.Error("fetching cloud migration access token", "err", err.Error())
} }
@@ -112,7 +117,10 @@ func (cma *CloudMigrationAPI) CreateToken(c *contextmodel.ReqContext) response.R
resp, err := cma.cloudMigrationService.CreateToken(ctx) resp, err := cma.cloudMigrationService.CreateToken(ctx)
if err != nil { if err != nil {
span.SetStatus(codes.Error, "creating gcom access token")
span.RecordError(err)
logger.Error("creating gcom access token", "err", err.Error()) logger.Error("creating gcom access token", "err", err.Error())
return response.ErrOrFallback(http.StatusInternalServerError, "creating gcom access token", err) return response.ErrOrFallback(http.StatusInternalServerError, "creating gcom access token", err)
} }
@@ -137,11 +145,17 @@ func (cma *CloudMigrationAPI) DeleteToken(c *contextmodel.ReqContext) response.R
uid := web.Params(c.Req)[":uid"] uid := web.Params(c.Req)[":uid"]
if err := util.ValidateUID(uid); err != nil { if err := util.ValidateUID(uid); err != nil {
span.SetStatus(codes.Error, "invalid migration uid")
span.RecordError(err)
return response.Error(http.StatusBadRequest, "invalid migration uid", err) return response.Error(http.StatusBadRequest, "invalid migration uid", err)
} }
if err := cma.cloudMigrationService.DeleteToken(ctx, uid); err != nil { if err := cma.cloudMigrationService.DeleteToken(ctx, uid); err != nil {
span.SetStatus(codes.Error, "deleting cloud migration token")
span.RecordError(err)
logger.Error("deleting cloud migration token", "err", err.Error()) logger.Error("deleting cloud migration token", "err", err.Error())
return response.ErrOrFallback(http.StatusInternalServerError, "deleting cloud migration token", err) return response.ErrOrFallback(http.StatusInternalServerError, "deleting cloud migration token", err)
} }
@@ -163,6 +177,9 @@ func (cma *CloudMigrationAPI) GetSessionList(c *contextmodel.ReqContext) respons
sl, err := cma.cloudMigrationService.GetSessionList(ctx) sl, err := cma.cloudMigrationService.GetSessionList(ctx)
if err != nil { if err != nil {
span.SetStatus(codes.Error, "session list error")
span.RecordError(err)
return response.ErrOrFallback(http.StatusInternalServerError, "session list error", err) return response.ErrOrFallback(http.StatusInternalServerError, "session list error", err)
} }
@@ -185,11 +202,17 @@ func (cma *CloudMigrationAPI) GetSession(c *contextmodel.ReqContext) response.Re
uid := web.Params(c.Req)[":uid"] uid := web.Params(c.Req)[":uid"]
if err := util.ValidateUID(uid); err != nil { if err := util.ValidateUID(uid); err != nil {
span.SetStatus(codes.Error, "invalid session uid")
span.RecordError(err)
return response.Error(http.StatusBadRequest, "invalid session uid", err) return response.Error(http.StatusBadRequest, "invalid session uid", err)
} }
s, err := cma.cloudMigrationService.GetSession(ctx, uid) s, err := cma.cloudMigrationService.GetSession(ctx, uid)
if err != nil { if err != nil {
span.SetStatus(codes.Error, "session not found")
span.RecordError(err)
return response.ErrOrFallback(http.StatusNotFound, "session not found", err) return response.ErrOrFallback(http.StatusNotFound, "session not found", err)
} }
@@ -217,12 +240,18 @@ func (cma *CloudMigrationAPI) CreateSession(c *contextmodel.ReqContext) response
cmd := CloudMigrationSessionRequestDTO{} cmd := CloudMigrationSessionRequestDTO{}
if err := web.Bind(c.Req, &cmd); err != nil { if err := web.Bind(c.Req, &cmd); err != nil {
span.SetStatus(codes.Error, "bad request data")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "bad request data", err) return response.ErrOrFallback(http.StatusBadRequest, "bad request data", err)
} }
s, err := cma.cloudMigrationService.CreateSession(ctx, cloudmigration.CloudMigrationSessionRequest{ s, err := cma.cloudMigrationService.CreateSession(ctx, cloudmigration.CloudMigrationSessionRequest{
AuthToken: cmd.AuthToken, AuthToken: cmd.AuthToken,
}) })
if err != nil { if err != nil {
span.SetStatus(codes.Error, "session creation error")
span.RecordError(err)
return response.ErrOrFallback(http.StatusInternalServerError, "session creation error", err) return response.ErrOrFallback(http.StatusInternalServerError, "session creation error", err)
} }
@@ -250,11 +279,17 @@ func (cma *CloudMigrationAPI) DeleteSession(c *contextmodel.ReqContext) response
uid := web.Params(c.Req)[":uid"] uid := web.Params(c.Req)[":uid"]
if err := util.ValidateUID(uid); err != nil { if err := util.ValidateUID(uid); err != nil {
span.SetStatus(codes.Error, "invalid session uid")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err)
} }
_, err := cma.cloudMigrationService.DeleteSession(ctx, uid) _, err := cma.cloudMigrationService.DeleteSession(ctx, uid)
if err != nil { if err != nil {
span.SetStatus(codes.Error, "session delete error")
span.RecordError(err)
return response.ErrOrFallback(http.StatusInternalServerError, "session delete error", err) return response.ErrOrFallback(http.StatusInternalServerError, "session delete error", err)
} }
return response.Empty(http.StatusOK) return response.Empty(http.StatusOK)
@@ -278,11 +313,17 @@ func (cma *CloudMigrationAPI) CreateSnapshot(c *contextmodel.ReqContext) respons
uid := web.Params(c.Req)[":uid"] uid := web.Params(c.Req)[":uid"]
if err := util.ValidateUID(uid); err != nil { if err := util.ValidateUID(uid); err != nil {
span.SetStatus(codes.Error, "invalid session uid")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err)
} }
ss, err := cma.cloudMigrationService.CreateSnapshot(ctx, c.SignedInUser, uid) ss, err := cma.cloudMigrationService.CreateSnapshot(ctx, c.SignedInUser, uid)
if err != nil { if err != nil {
span.SetStatus(codes.Error, "error creating snapshot")
span.RecordError(err)
return response.ErrOrFallback(http.StatusInternalServerError, "error creating snapshot", err) return response.ErrOrFallback(http.StatusInternalServerError, "error creating snapshot", err)
} }
@@ -307,9 +348,15 @@ func (cma *CloudMigrationAPI) GetSnapshot(c *contextmodel.ReqContext) response.R
sessUid, snapshotUid := web.Params(c.Req)[":uid"], web.Params(c.Req)[":snapshotUid"] sessUid, snapshotUid := web.Params(c.Req)[":uid"], web.Params(c.Req)[":snapshotUid"]
if err := util.ValidateUID(sessUid); err != nil { if err := util.ValidateUID(sessUid); err != nil {
span.SetStatus(codes.Error, "invalid session uid")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err)
} }
if err := util.ValidateUID(snapshotUid); err != nil { if err := util.ValidateUID(snapshotUid); err != nil {
span.SetStatus(codes.Error, "invalid snapshot uid")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err) return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err)
} }
@@ -327,6 +374,9 @@ func (cma *CloudMigrationAPI) GetSnapshot(c *contextmodel.ReqContext) response.R
} }
snapshot, err := cma.cloudMigrationService.GetSnapshot(ctx, q) snapshot, err := cma.cloudMigrationService.GetSnapshot(ctx, q)
if err != nil { if err != nil {
span.SetStatus(codes.Error, "error retrieving snapshot")
span.RecordError(err)
return response.ErrOrFallback(http.StatusInternalServerError, "error retrieving snapshot", err) return response.ErrOrFallback(http.StatusInternalServerError, "error retrieving snapshot", err)
} }
@@ -335,11 +385,12 @@ func (cma *CloudMigrationAPI) GetSnapshot(c *contextmodel.ReqContext) response.R
dtoResults := make([]MigrateDataResponseItemDTO, len(results)) dtoResults := make([]MigrateDataResponseItemDTO, len(results))
for i := 0; i < len(results); i++ { for i := 0; i < len(results); i++ {
dtoResults[i] = MigrateDataResponseItemDTO{ dtoResults[i] = MigrateDataResponseItemDTO{
Name: results[i].Name, Name: results[i].Name,
Type: MigrateDataType(results[i].Type), Type: MigrateDataType(results[i].Type),
RefID: results[i].RefID, RefID: results[i].RefID,
Status: ItemStatus(results[i].Status), Status: ItemStatus(results[i].Status),
Message: results[i].Error, Message: results[i].Error,
ParentName: results[i].ParentName,
} }
} }
@@ -386,6 +437,9 @@ func (cma *CloudMigrationAPI) GetSnapshotList(c *contextmodel.ReqContext) respon
uid := web.Params(c.Req)[":uid"] uid := web.Params(c.Req)[":uid"]
if err := util.ValidateUID(uid); err != nil { if err := util.ValidateUID(uid); err != nil {
span.SetStatus(codes.Error, "invalid session uid")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err)
} }
q := cloudmigration.ListSnapshotsQuery{ q := cloudmigration.ListSnapshotsQuery{
@@ -403,6 +457,9 @@ func (cma *CloudMigrationAPI) GetSnapshotList(c *contextmodel.ReqContext) respon
snapshotList, err := cma.cloudMigrationService.GetSnapshotList(ctx, q) snapshotList, err := cma.cloudMigrationService.GetSnapshotList(ctx, q)
if err != nil { if err != nil {
span.SetStatus(codes.Error, "error retrieving snapshot list")
span.RecordError(err)
return response.ErrOrFallback(http.StatusInternalServerError, "error retrieving snapshot list", err) return response.ErrOrFallback(http.StatusInternalServerError, "error retrieving snapshot list", err)
} }
@@ -438,13 +495,22 @@ func (cma *CloudMigrationAPI) UploadSnapshot(c *contextmodel.ReqContext) respons
sessUid, snapshotUid := web.Params(c.Req)[":uid"], web.Params(c.Req)[":snapshotUid"] sessUid, snapshotUid := web.Params(c.Req)[":uid"], web.Params(c.Req)[":snapshotUid"]
if err := util.ValidateUID(sessUid); err != nil { if err := util.ValidateUID(sessUid); err != nil {
span.SetStatus(codes.Error, "invalid session uid")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err)
} }
if err := util.ValidateUID(snapshotUid); err != nil { if err := util.ValidateUID(snapshotUid); err != nil {
span.SetStatus(codes.Error, "invalid snapshot uid")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err) return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err)
} }
if err := cma.cloudMigrationService.UploadSnapshot(ctx, sessUid, snapshotUid); err != nil { if err := cma.cloudMigrationService.UploadSnapshot(ctx, sessUid, snapshotUid); err != nil {
span.SetStatus(codes.Error, "error uploading snapshot")
span.RecordError(err)
return response.ErrOrFallback(http.StatusInternalServerError, "error uploading snapshot", err) return response.ErrOrFallback(http.StatusInternalServerError, "error uploading snapshot", err)
} }
@@ -468,13 +534,22 @@ func (cma *CloudMigrationAPI) CancelSnapshot(c *contextmodel.ReqContext) respons
sessUid, snapshotUid := web.Params(c.Req)[":uid"], web.Params(c.Req)[":snapshotUid"] sessUid, snapshotUid := web.Params(c.Req)[":uid"], web.Params(c.Req)[":snapshotUid"]
if err := util.ValidateUID(sessUid); err != nil { if err := util.ValidateUID(sessUid); err != nil {
span.SetStatus(codes.Error, "invalid session uid")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err) return response.ErrOrFallback(http.StatusBadRequest, "invalid session uid", err)
} }
if err := util.ValidateUID(snapshotUid); err != nil { if err := util.ValidateUID(snapshotUid); err != nil {
span.SetStatus(codes.Error, "invalid snapshot uid")
span.RecordError(err)
return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err) return response.ErrOrFallback(http.StatusBadRequest, "invalid snapshot uid", err)
} }
if err := cma.cloudMigrationService.CancelSnapshot(ctx, sessUid, snapshotUid); err != nil { if err := cma.cloudMigrationService.CancelSnapshot(ctx, sessUid, snapshotUid); err != nil {
span.SetStatus(codes.Error, "error canceling snapshot")
span.RecordError(err)
return response.ErrOrFallback(http.StatusInternalServerError, "error canceling snapshot", err) return response.ErrOrFallback(http.StatusInternalServerError, "error canceling snapshot", err)
} }
+1 -1
View File
@@ -345,7 +345,7 @@ func TestCloudMigrationAPI_GetSnapshot(t *testing.T) {
requestUrl: "/api/cloudmigration/migration/1234/snapshot/1", requestUrl: "/api/cloudmigration/migration/1234/snapshot/1",
basicRole: org.RoleAdmin, basicRole: org.RoleAdmin,
expectedHttpResult: http.StatusOK, expectedHttpResult: http.StatusOK,
expectedBody: `{"uid":"fake_uid","status":"CREATING","sessionUid":"1234","created":"0001-01-01T00:00:00Z","finished":"0001-01-01T00:00:00Z","results":[],"stats":{"types":{},"statuses":{},"total":0}}`, 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: "should return 403 if no used is not admin",
+11 -5
View File
@@ -106,7 +106,8 @@ type MigrateDataResponseDTO struct {
} }
type MigrateDataResponseItemDTO struct { type MigrateDataResponseItemDTO struct {
Name string `json:"name"` Name string `json:"name"`
ParentName string `json:"parentName"`
// required:true // required:true
Type MigrateDataType `json:"type"` Type MigrateDataType `json:"type"`
// required:true // required:true
@@ -120,10 +121,15 @@ type MigrateDataResponseItemDTO struct {
type MigrateDataType string type MigrateDataType string
const ( const (
DashboardDataType MigrateDataType = "DASHBOARD" DashboardDataType MigrateDataType = "DASHBOARD"
DatasourceDataType MigrateDataType = "DATASOURCE" DatasourceDataType MigrateDataType = "DATASOURCE"
FolderDataType MigrateDataType = "FOLDER" FolderDataType MigrateDataType = "FOLDER"
LibraryElementDataType MigrateDataType = "LIBRARY_ELEMENT" LibraryElementDataType MigrateDataType = "LIBRARY_ELEMENT"
AlertRuleType MigrateDataType = "ALERT_RULE"
ContactPointType MigrateDataType = "CONTACT_POINT"
NotificationPolicyType MigrateDataType = "NOTIFICATION_POLICY"
NotificationTemplateType MigrateDataType = "NOTIFICATION_TEMPLATE"
MuteTimingType MigrateDataType = "MUTE_TIMING"
) )
// swagger:enum ItemStatus // swagger:enum ItemStatus
@@ -12,6 +12,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/kvstore"
@@ -27,6 +28,7 @@ import (
"github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/gcom" "github.com/grafana/grafana/pkg/services/gcom"
"github.com/grafana/grafana/pkg/services/libraryelements" "github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/ngalert"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets"
secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore"
@@ -35,6 +37,7 @@ import (
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
) )
@@ -62,6 +65,7 @@ type Service struct {
secretsService secrets.Service secretsService secrets.Service
kvStore *kvstore.NamespacedKVStore kvStore *kvstore.NamespacedKVStore
libraryElementsService libraryelements.Service libraryElementsService libraryelements.Service
ngAlert *ngalert.AlertNG
api *api.CloudMigrationAPI api *api.CloudMigrationAPI
tracer tracing.Tracer tracer tracing.Tracer
@@ -83,6 +87,7 @@ var _ cloudmigration.Service = (*Service)(nil)
// builds the service, and api, and configures routes // builds the service, and api, and configures routes
func ProvideService( func ProvideService(
cfg *setting.Cfg, cfg *setting.Cfg,
httpClientProvider *httpclient.Provider,
features featuremgmt.FeatureToggles, features featuremgmt.FeatureToggles,
db db.DB, db db.DB,
dsService datasources.DataSourceService, dsService datasources.DataSourceService,
@@ -96,6 +101,7 @@ func ProvideService(
pluginStore pluginstore.Store, pluginStore pluginstore.Store,
kvStore kvstore.KVStore, kvStore kvstore.KVStore,
libraryElementsService libraryelements.Service, libraryElementsService libraryelements.Service,
ngAlert *ngalert.AlertNG,
) (cloudmigration.Service, error) { ) (cloudmigration.Service, error) {
if !features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) { if !features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) {
return &NoopServiceImpl{}, nil return &NoopServiceImpl{}, nil
@@ -115,18 +121,33 @@ func ProvideService(
pluginStore: pluginStore, pluginStore: pluginStore,
kvStore: kvstore.WithNamespace(kvStore, 0, "cloudmigration"), kvStore: kvstore.WithNamespace(kvStore, 0, "cloudmigration"),
libraryElementsService: libraryElementsService, libraryElementsService: libraryElementsService,
ngAlert: ngAlert,
} }
s.api = api.RegisterApi(routeRegister, s, tracer) s.api = api.RegisterApi(routeRegister, s, tracer)
s.objectStorage = objectstorage.NewS3() httpClientS3, err := httpClientProvider.New()
if err != nil {
return nil, fmt.Errorf("creating http client for S3: %w", err)
}
s.objectStorage = objectstorage.NewS3(httpClientS3, tracer)
if !cfg.CloudMigration.IsDeveloperMode { if !cfg.CloudMigration.IsDeveloperMode {
c, err := gmsclient.NewGMSClient(cfg) httpClientGMS, err := httpClientProvider.New()
if err != nil {
return nil, fmt.Errorf("creating http client for GMS: %w", err)
}
c, err := gmsclient.NewGMSClient(cfg, httpClientGMS)
if err != nil { if err != nil {
return nil, fmt.Errorf("initializing GMS client: %w", err) return nil, fmt.Errorf("initializing GMS client: %w", err)
} }
s.gmsClient = c s.gmsClient = c
s.gcomService = gcom.New(gcom.Config{ApiURL: cfg.GrafanaComAPIURL, Token: cfg.CloudMigration.GcomAPIToken})
httpClientGcom, err := httpClientProvider.New()
if err != nil {
return nil, fmt.Errorf("creating http client for GCOM: %w", err)
}
s.gcomService = gcom.New(gcom.Config{ApiURL: cfg.GrafanaComAPIURL, Token: cfg.CloudMigration.GcomAPIToken}, httpClientGcom)
} else { } else {
s.gmsClient = gmsclient.NewInMemoryClient() s.gmsClient = gmsclient.NewInMemoryClient()
s.gcomService = &gcomStub{policies: map[string]gcom.AccessPolicy{}, token: nil} s.gcomService = &gcomStub{policies: map[string]gcom.AccessPolicy{}, token: nil}
@@ -169,7 +190,8 @@ func (s *Service) GetToken(ctx context.Context) (gcom.TokenView, error) {
RequestID: requestID, RequestID: requestID,
Region: instance.RegionSlug, Region: instance.RegionSlug,
AccessPolicyName: accessPolicyName, AccessPolicyName: accessPolicyName,
TokenName: accessTokenName}) TokenName: accessTokenName,
})
if err != nil { if err != nil {
return gcom.TokenView{}, fmt.Errorf("listing tokens: %w", err) return gcom.TokenView{}, fmt.Errorf("listing tokens: %w", err)
} }
@@ -279,9 +301,6 @@ func (s *Service) CreateToken(ctx context.Context) (cloudmigration.CreateAccessT
} }
func (s *Service) findAccessPolicyByName(ctx context.Context, regionSlug, accessPolicyName string) (*gcom.AccessPolicy, error) { func (s *Service) findAccessPolicyByName(ctx context.Context, regionSlug, accessPolicyName string) (*gcom.AccessPolicy, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.findAccessPolicyByName")
defer span.End()
accessPolicies, err := s.gcomService.ListAccessPolicies(ctx, gcom.ListAccessPoliciesParams{ accessPolicies, err := s.gcomService.ListAccessPolicies(ctx, gcom.ListAccessPoliciesParams{
RequestID: tracing.TraceIDFromContext(ctx, false), RequestID: tracing.TraceIDFromContext(ctx, false),
Region: regionSlug, Region: regionSlug,
@@ -341,7 +360,7 @@ func (s *Service) DeleteToken(ctx context.Context, tokenID string) error {
} }
func (s *Service) GetSession(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, error) { func (s *Service) GetSession(ctx context.Context, uid string) (*cloudmigration.CloudMigrationSession, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetMigration") ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetSession")
defer span.End() defer span.End()
migration, err := s.store.GetMigrationSessionByUID(ctx, uid) migration, err := s.store.GetMigrationSessionByUID(ctx, uid)
if err != nil { if err != nil {
@@ -352,6 +371,9 @@ func (s *Service) GetSession(ctx context.Context, uid string) (*cloudmigration.C
} }
func (s *Service) GetSessionList(ctx context.Context) (*cloudmigration.CloudMigrationSessionListResponse, error) { func (s *Service) GetSessionList(ctx context.Context) (*cloudmigration.CloudMigrationSessionListResponse, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.GetSessionList")
defer span.End()
values, err := s.store.GetCloudMigrationSessionList(ctx) values, err := s.store.GetCloudMigrationSessionList(ctx)
if err != nil { if err != nil {
return nil, fmt.Errorf("retrieving session list from store: %w", err) return nil, fmt.Errorf("retrieving session list from store: %w", err)
@@ -370,7 +392,7 @@ func (s *Service) GetSessionList(ctx context.Context) (*cloudmigration.CloudMigr
} }
func (s *Service) CreateSession(ctx context.Context, cmd cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) { func (s *Service) CreateSession(ctx context.Context, cmd cloudmigration.CloudMigrationSessionRequest) (*cloudmigration.CloudMigrationSessionResponse, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.createMigration") ctx, span := s.tracer.Start(ctx, "CloudMigrationService.CreateSession")
defer span.End() defer span.End()
base64Token := cmd.AuthToken base64Token := cmd.AuthToken
@@ -405,6 +427,9 @@ func (s *Service) CreateSession(ctx context.Context, cmd cloudmigration.CloudMig
} }
func (s *Service) DeleteSession(ctx context.Context, sessionUID string) (*cloudmigration.CloudMigrationSession, error) { func (s *Service) DeleteSession(ctx context.Context, sessionUID string) (*cloudmigration.CloudMigrationSession, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.DeleteSession")
defer span.End()
session, snapshots, err := s.store.DeleteMigrationSessionByUID(ctx, sessionUID) session, snapshots, err := s.store.DeleteMigrationSessionByUID(ctx, sessionUID)
if err != nil { if err != nil {
s.report(ctx, session, gmsclient.EventDisconnect, 0, err) s.report(ctx, session, gmsclient.EventDisconnect, 0, err)
@@ -470,26 +495,36 @@ func (s *Service) CreateSnapshot(ctx context.Context, signedInUser *user.SignedI
s.cancelMutex.Unlock() s.cancelMutex.Unlock()
}() }()
ctx, cancelFunc := context.WithCancel(context.Background()) // Create context out the span context to ensure the trace is propagated
asyncCtx := trace.ContextWithSpanContext(context.Background(), span.SpanContext())
asyncCtx, asyncSpan := s.tracer.Start(asyncCtx, "CloudMigrationService.CreateSnapshotAsync")
defer asyncSpan.End()
asyncCtx, cancelFunc := context.WithCancel(asyncCtx)
s.cancelFunc = cancelFunc s.cancelFunc = cancelFunc
s.report(ctx, session, gmsclient.EventStartBuildingSnapshot, 0, nil) s.report(asyncCtx, session, gmsclient.EventStartBuildingSnapshot, 0, nil)
start := time.Now() start := time.Now()
err := s.buildSnapshot(ctx, signedInUser, initResp.MaxItemsPerPartition, initResp.Metadata, snapshot) err := s.buildSnapshot(asyncCtx, signedInUser, initResp.MaxItemsPerPartition, initResp.Metadata, snapshot)
if err != nil { if err != nil {
asyncSpan.SetStatus(codes.Error, "error building snapshot")
asyncSpan.RecordError(err)
s.log.Error("building snapshot", "err", err.Error()) s.log.Error("building snapshot", "err", err.Error())
// Update status to error with retries // Update status to error with retries
if err := s.updateSnapshotWithRetries(context.Background(), cloudmigration.UpdateSnapshotCmd{ if err := s.updateSnapshotWithRetries(asyncCtx, cloudmigration.UpdateSnapshotCmd{
UID: snapshot.UID, UID: snapshot.UID,
SessionID: sessionUid, SessionID: sessionUid,
Status: cloudmigration.SnapshotStatusError, Status: cloudmigration.SnapshotStatusError,
}); err != nil { }); err != nil {
s.log.Error("critical failure during snapshot creation - please report any error logs") s.log.Error("critical failure during snapshot creation - please report any error logs")
asyncSpan.RecordError(err)
} }
} }
s.report(ctx, session, gmsclient.EventDoneBuildingSnapshot, time.Since(start), err) span.SetStatus(codes.Ok, "snapshot built")
s.report(asyncCtx, session, gmsclient.EventDoneBuildingSnapshot, time.Since(start), err)
}() }()
return &snapshot, nil return &snapshot, nil
@@ -624,32 +659,48 @@ func (s *Service) UploadSnapshot(ctx context.Context, sessionUid string, snapsho
s.cancelMutex.Unlock() s.cancelMutex.Unlock()
}() }()
ctx, cancelFunc := context.WithCancel(context.Background()) // Create context out the span context to ensure the trace is propagated
s.cancelFunc = cancelFunc asyncCtx := trace.ContextWithSpanContext(context.Background(), span.SpanContext())
asyncCtx, asyncSpan := s.tracer.Start(asyncCtx, "CloudMigrationService.UploadSnapshot")
defer asyncSpan.End()
s.report(ctx, session, gmsclient.EventStartUploadingSnapshot, 0, nil) asyncCtx, s.cancelFunc = context.WithCancel(asyncCtx)
s.report(asyncCtx, session, gmsclient.EventStartUploadingSnapshot, 0, nil)
start := time.Now() start := time.Now()
err := s.uploadSnapshot(ctx, session, snapshot, uploadUrl) err := s.uploadSnapshot(asyncCtx, session, snapshot, uploadUrl)
if err != nil { if err != nil {
asyncSpan.SetStatus(codes.Error, "error uploading snapshot")
asyncSpan.RecordError(err)
s.log.Error("uploading snapshot", "err", err.Error()) s.log.Error("uploading snapshot", "err", err.Error())
// Update status to error with retries // Update status to error with retries
if err := s.updateSnapshotWithRetries(context.Background(), cloudmigration.UpdateSnapshotCmd{ if err := s.updateSnapshotWithRetries(asyncCtx, cloudmigration.UpdateSnapshotCmd{
UID: snapshot.UID, UID: snapshot.UID,
SessionID: sessionUid, SessionID: sessionUid,
Status: cloudmigration.SnapshotStatusError, Status: cloudmigration.SnapshotStatusError,
}); err != nil { }); err != nil {
asyncSpan.RecordError(err)
s.log.Error("critical failure during snapshot upload - please report any error logs") s.log.Error("critical failure during snapshot upload - please report any error logs")
} }
} }
s.report(ctx, session, gmsclient.EventDoneUploadingSnapshot, time.Since(start), err) s.report(asyncCtx, session, gmsclient.EventDoneUploadingSnapshot, time.Since(start), err)
}() }()
return nil return nil
} }
func (s *Service) CancelSnapshot(ctx context.Context, sessionUid string, snapshotUid string) (err error) { func (s *Service) CancelSnapshot(ctx context.Context, sessionUid string, snapshotUid string) (err error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.CancelSnapshot",
trace.WithAttributes(
attribute.String("sessionUid", sessionUid),
attribute.String("snapshotUid", snapshotUid),
),
)
defer span.End()
// The cancel func itself is protected by a mutex in the async threads, so it may or may not be set by the time CancelSnapshot is called // The cancel func itself is protected by a mutex in the async threads, so it may or may not be set by the time CancelSnapshot is called
// Attempt to cancel and recover from the panic if the cancel function is nil // Attempt to cancel and recover from the panic if the cancel function is nil
defer func() { defer func() {
@@ -684,6 +735,9 @@ func (s *Service) report(
d time.Duration, d time.Duration,
evtErr error, evtErr error,
) { ) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.report")
defer span.End()
id, err := s.getLocalEventId(ctx) id, err := s.getLocalEventId(ctx)
if err != nil { if err != nil {
s.log.Error("failed to report event", "type", t, "error", err.Error()) s.log.Error("failed to report event", "type", t, "error", err.Error())
@@ -738,6 +792,9 @@ func (s *Service) getLocalEventId(ctx context.Context) (string, error) {
} }
func (s *Service) deleteLocalFiles(snapshots []cloudmigration.CloudMigrationSnapshot) error { func (s *Service) deleteLocalFiles(snapshots []cloudmigration.CloudMigrationSnapshot) error {
_, span := s.tracer.Start(context.Background(), "CloudMigrationService.deleteLocalFiles")
defer span.End()
var err error var err error
for _, snapshot := range snapshots { for _, snapshot := range snapshots {
err = os.RemoveAll(snapshot.LocalDir) err = os.RemoveAll(snapshot.LocalDir)
@@ -2,18 +2,24 @@ package cloudmigrationimpl
import ( import (
"context" "context"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/httpclient"
"github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/kvstore"
"github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
"github.com/grafana/grafana/pkg/services/cloudmigration" "github.com/grafana/grafana/pkg/services/cloudmigration"
"github.com/grafana/grafana/pkg/services/cloudmigration/gmsclient" "github.com/grafana/grafana/pkg/services/cloudmigration/gmsclient"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
@@ -26,7 +32,12 @@ import (
"github.com/grafana/grafana/pkg/services/folder/foldertest" "github.com/grafana/grafana/pkg/services/folder/foldertest"
libraryelementsfake "github.com/grafana/grafana/pkg/services/libraryelements/fake" libraryelementsfake "github.com/grafana/grafana/pkg/services/libraryelements/fake"
libraryelements "github.com/grafana/grafana/pkg/services/libraryelements/model" libraryelements "github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/ngalert"
"github.com/grafana/grafana/pkg/services/ngalert/metrics"
ngalertstore "github.com/grafana/grafana/pkg/services/ngalert/store"
ngalertfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
secretsfakes "github.com/grafana/grafana/pkg/services/secrets/fakes" secretsfakes "github.com/grafana/grafana/pkg/services/secrets/fakes"
secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore" secretskv "github.com/grafana/grafana/pkg/services/secrets/kvstore"
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
@@ -391,6 +402,7 @@ func Test_NonCoreDataSourcesHaveWarning(t *testing.T) {
Results: []cloudmigration.CloudMigrationResource{ Results: []cloudmigration.CloudMigrationResource{
{ {
Name: "1 name", Name: "1 name",
ParentName: "1 parent name",
Type: cloudmigration.DatasourceDataType, Type: cloudmigration.DatasourceDataType,
RefID: "1", // this will be core RefID: "1", // this will be core
Status: cloudmigration.ItemStatusOK, Status: cloudmigration.ItemStatusOK,
@@ -398,6 +410,7 @@ func Test_NonCoreDataSourcesHaveWarning(t *testing.T) {
}, },
{ {
Name: "2 name", Name: "2 name",
ParentName: "",
Type: cloudmigration.DatasourceDataType, Type: cloudmigration.DatasourceDataType,
RefID: "2", // this will be non-core RefID: "2", // this will be non-core
Status: cloudmigration.ItemStatusOK, Status: cloudmigration.ItemStatusOK,
@@ -405,6 +418,7 @@ func Test_NonCoreDataSourcesHaveWarning(t *testing.T) {
}, },
{ {
Name: "3 name", Name: "3 name",
ParentName: "3 parent name",
Type: cloudmigration.DatasourceDataType, Type: cloudmigration.DatasourceDataType,
RefID: "3", // this will be non-core with an error RefID: "3", // this will be non-core with an error
Status: cloudmigration.ItemStatusError, Status: cloudmigration.ItemStatusError,
@@ -413,6 +427,7 @@ func Test_NonCoreDataSourcesHaveWarning(t *testing.T) {
}, },
{ {
Name: "4 name", Name: "4 name",
ParentName: "4 folder name",
Type: cloudmigration.DatasourceDataType, Type: cloudmigration.DatasourceDataType,
RefID: "4", // this will be deleted RefID: "4", // this will be deleted
Status: cloudmigration.ItemStatusOK, Status: cloudmigration.ItemStatusOK,
@@ -563,6 +578,122 @@ func TestReportEvent(t *testing.T) {
require.Equal(t, 1, gmsMock.reportEventCalled) require.Equal(t, 1, gmsMock.reportEventCalled)
}) })
} }
func TestGetFolderNamesForFolderUIDs(t *testing.T) {
s := setUpServiceTest(t, false).(*Service)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
user := &user.SignedInUser{OrgID: 1}
testcases := []struct {
folders []*folder.Folder
folderUIDs []string
expectedFolderNames []string
}{
{
folders: []*folder.Folder{
{UID: "folderUID-A", Title: "Folder A", OrgID: 1},
{UID: "folderUID-B", Title: "Folder B", OrgID: 1},
},
folderUIDs: []string{"folderUID-A", "folderUID-B"},
expectedFolderNames: []string{"Folder A", "Folder B"},
},
{
folders: []*folder.Folder{
{UID: "folderUID-A", Title: "Folder A", OrgID: 1},
},
folderUIDs: []string{"folderUID-A"},
expectedFolderNames: []string{"Folder A"},
},
{
folders: []*folder.Folder{},
folderUIDs: []string{"folderUID-A"},
expectedFolderNames: []string{""},
},
{
folders: []*folder.Folder{
{UID: "folderUID-A", Title: "Folder A", OrgID: 1},
},
folderUIDs: []string{"folderUID-A", "folderUID-B"},
expectedFolderNames: []string{"Folder A", ""},
},
{
folders: []*folder.Folder{},
folderUIDs: []string{""},
expectedFolderNames: []string{""},
},
{
folders: []*folder.Folder{},
folderUIDs: []string{},
expectedFolderNames: []string{},
},
}
for _, tc := range testcases {
s.folderService = &foldertest.FakeService{ExpectedFolders: tc.folders}
folderUIDsToFolders, err := s.getFolderNamesForFolderUIDs(ctx, user, tc.folderUIDs)
require.NoError(t, err)
resFolderNames := slices.Collect(maps.Values(folderUIDsToFolders))
require.Len(t, resFolderNames, len(tc.expectedFolderNames))
require.ElementsMatch(t, resFolderNames, tc.expectedFolderNames)
}
}
func TestGetParentNames(t *testing.T) {
s := setUpServiceTest(t, false).(*Service)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
user := &user.SignedInUser{OrgID: 1}
libraryElementFolderUID := "folderUID-A"
testcases := []struct {
fakeFolders []*folder.Folder
folders []folder.CreateFolderCommand
dashboards []dashboards.Dashboard
libraryElements []libraryElement
expectedDashParentNames []string
expectedFoldParentNames []string
}{
{
fakeFolders: []*folder.Folder{
{UID: "folderUID-A", Title: "Folder A", OrgID: 1, ParentUID: ""},
{UID: "folderUID-B", Title: "Folder B", OrgID: 1, ParentUID: "folderUID-A"},
},
folders: []folder.CreateFolderCommand{
{UID: "folderUID-C", Title: "Folder A", OrgID: 1, ParentUID: "folderUID-A"},
},
dashboards: []dashboards.Dashboard{
{UID: "dashboardUID-0", OrgID: 1, FolderUID: ""},
{UID: "dashboardUID-1", OrgID: 1, FolderUID: "folderUID-A"},
{UID: "dashboardUID-2", OrgID: 1, FolderUID: "folderUID-B"},
},
libraryElements: []libraryElement{
{UID: "libraryElementUID-0", FolderUID: &libraryElementFolderUID},
{UID: "libraryElementUID-1"},
},
expectedDashParentNames: []string{"", "Folder A", "Folder B"},
expectedFoldParentNames: []string{"Folder A"},
},
}
for _, tc := range testcases {
s.folderService = &foldertest.FakeService{ExpectedFolders: tc.fakeFolders}
dataUIDsToParentNamesByType, err := s.getParentNames(ctx, user, tc.dashboards, tc.folders, tc.libraryElements)
require.NoError(t, err)
resDashParentNames := slices.Collect(maps.Values(dataUIDsToParentNamesByType[cloudmigration.DashboardDataType]))
require.Len(t, resDashParentNames, len(tc.expectedDashParentNames))
require.ElementsMatch(t, resDashParentNames, tc.expectedDashParentNames)
resFoldParentNames := slices.Collect(maps.Values(dataUIDsToParentNamesByType[cloudmigration.FolderDataType]))
require.Len(t, resFoldParentNames, len(tc.expectedFoldParentNames))
require.ElementsMatch(t, resFoldParentNames, tc.expectedFoldParentNames)
}
}
func TestGetLibraryElementsCommands(t *testing.T) { func TestGetLibraryElementsCommands(t *testing.T) {
s := setUpServiceTest(t, false).(*Service) s := setUpServiceTest(t, false).(*Service)
@@ -642,8 +773,29 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool) cloudmigration.Servi
}, },
} }
featureToggles := featuremgmt.WithFeatures(featuremgmt.FlagOnPremToCloudMigrations, featuremgmt.FlagDashboardRestore)
kvStore := kvstore.ProvideService(sqlStore)
bus := bus.ProvideBus(tracer)
fakeAccessControl := actest.FakeAccessControl{}
fakeAccessControlService := actest.FakeService{}
alertMetrics := metrics.NewNGAlert(prometheus.NewRegistry())
ruleStore, err := ngalertstore.ProvideDBStore(cfg, featureToggles, sqlStore, mockFolder, dashboardService, fakeAccessControl)
require.NoError(t, err)
ng, err := ngalert.ProvideService(
cfg, featureToggles, nil, nil, rr, sqlStore, kvStore, nil, nil, quotatest.New(false, nil),
secretsService, nil, alertMetrics, mockFolder, fakeAccessControl, dashboardService, nil, bus, fakeAccessControlService,
annotationstest.NewFakeAnnotationsRepo(), &pluginstore.FakePluginStore{}, tracer, ruleStore,
httpclient.NewProvider(), ngalertfakes.NewFakeReceiverPermissionsService(),
)
require.NoError(t, err)
s, err := ProvideService( s, err := ProvideService(
cfg, cfg,
httpclient.NewProvider(),
featuremgmt.WithFeatures( featuremgmt.WithFeatures(
featuremgmt.FlagOnPremToCloudMigrations, featuremgmt.FlagOnPremToCloudMigrations,
featuremgmt.FlagDashboardRestore), featuremgmt.FlagDashboardRestore),
@@ -659,6 +811,7 @@ func setUpServiceTest(t *testing.T, withDashboardMock bool) cloudmigration.Servi
&pluginstore.FakePluginStore{}, &pluginstore.FakePluginStore{},
kvstore.ProvideService(sqlStore), kvstore.ProvideService(sqlStore),
&libraryelementsfake.LibraryElementService{}, &libraryelementsfake.LibraryElementService{},
ng,
) )
require.NoError(t, err) require.NoError(t, err)
@@ -98,10 +98,28 @@ func (m FakeServiceImpl) GetSnapshot(ctx context.Context, query cloudmigration.G
if m.ReturnError { if m.ReturnError {
return nil, fmt.Errorf("mock error") return nil, fmt.Errorf("mock error")
} }
cloudMigrationResources := []cloudmigration.CloudMigrationResource{
{
Type: cloudmigration.DashboardDataType,
RefID: "123",
Status: cloudmigration.ItemStatusPending,
Name: "dashboard name",
ParentName: "dashboard parent name",
},
{
Type: cloudmigration.DatasourceDataType,
RefID: "456",
Status: cloudmigration.ItemStatusOK,
Name: "datasource name",
ParentName: "dashboard parent name",
},
}
return &cloudmigration.CloudMigrationSnapshot{ return &cloudmigration.CloudMigrationSnapshot{
UID: "fake_uid", UID: "fake_uid",
SessionUID: "fake_uid", SessionUID: "fake_uid",
Status: cloudmigration.SnapshotStatusCreating, Status: cloudmigration.SnapshotStatusCreating,
Resources: cloudMigrationResources,
}, nil }, nil
} }
@@ -23,9 +23,21 @@ import (
"github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util/retryer" "github.com/grafana/grafana/pkg/util/retryer"
"golang.org/x/crypto/nacl/box" "golang.org/x/crypto/nacl/box"
"go.opentelemetry.io/otel/codes"
) )
var currentMigrationTypes = []cloudmigration.MigrateDataType{
cloudmigration.DatasourceDataType,
cloudmigration.FolderDataType,
cloudmigration.LibraryElementDataType,
cloudmigration.DashboardDataType,
}
func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.SignedInUser) (*cloudmigration.MigrateDataRequest, error) { func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.SignedInUser) (*cloudmigration.MigrateDataRequest, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getMigrationDataJSON")
defer span.End()
// Data sources // Data sources
dataSources, err := s.getDataSourceCommands(ctx) dataSources, err := s.getDataSourceCommands(ctx)
if err != nil { if err != nil {
@@ -95,14 +107,24 @@ func (s *Service) getMigrationDataJSON(ctx context.Context, signedInUser *user.S
}) })
} }
// Obtain the names of parent elements for Dashboard and Folders data types
parentNamesByType, err := s.getParentNames(ctx, signedInUser, dashs, folders, libraryElements)
if err != nil {
s.log.Error("Failed to get parent folder names", "err", err)
}
migrationData := &cloudmigration.MigrateDataRequest{ migrationData := &cloudmigration.MigrateDataRequest{
Items: migrationDataSlice, Items: migrationDataSlice,
ItemParentNames: parentNamesByType,
} }
return migrationData, nil return migrationData, nil
} }
func (s *Service) getDataSourceCommands(ctx context.Context) ([]datasources.AddDataSourceCommand, error) { func (s *Service) getDataSourceCommands(ctx context.Context) ([]datasources.AddDataSourceCommand, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getDataSourceCommands")
defer span.End()
dataSources, err := s.dsService.GetAllDataSources(ctx, &datasources.GetAllDataSourcesQuery{}) dataSources, err := s.dsService.GetAllDataSources(ctx, &datasources.GetAllDataSourcesQuery{})
if err != nil { if err != nil {
s.log.Error("Failed to get all datasources", "err", err) s.log.Error("Failed to get all datasources", "err", err)
@@ -141,6 +163,9 @@ func (s *Service) getDataSourceCommands(ctx context.Context) ([]datasources.AddD
// getDashboardAndFolderCommands returns the json payloads required by the dashboard and folder creation APIs // getDashboardAndFolderCommands returns the json payloads required by the dashboard and folder creation APIs
func (s *Service) getDashboardAndFolderCommands(ctx context.Context, signedInUser *user.SignedInUser) ([]dashboards.Dashboard, []folder.CreateFolderCommand, error) { func (s *Service) getDashboardAndFolderCommands(ctx context.Context, signedInUser *user.SignedInUser) ([]dashboards.Dashboard, []folder.CreateFolderCommand, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getDashboardAndFolderCommands")
defer span.End()
dashs, err := s.dashboardService.GetAllDashboards(ctx) dashs, err := s.dashboardService.GetAllDashboards(ctx)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -196,6 +221,9 @@ type libraryElement struct {
// getLibraryElementsCommands returns the json payloads required by the library elements creation API // getLibraryElementsCommands returns the json payloads required by the library elements creation API
func (s *Service) getLibraryElementsCommands(ctx context.Context, signedInUser *user.SignedInUser) ([]libraryElement, error) { func (s *Service) getLibraryElementsCommands(ctx context.Context, signedInUser *user.SignedInUser) ([]libraryElement, error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.getLibraryElementsCommands")
defer span.End()
const perPage = 100 const perPage = 100
cmds := make([]libraryElement, 0) cmds := make([]libraryElement, 0)
@@ -242,6 +270,9 @@ func (s *Service) getLibraryElementsCommands(ctx context.Context, signedInUser *
// asynchronous process for writing the snapshot to the filesystem and updating the snapshot status // asynchronous process for writing the snapshot to the filesystem and updating the snapshot status
func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedInUser, maxItemsPerPartition uint32, metadata []byte, snapshotMeta cloudmigration.CloudMigrationSnapshot) error { func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedInUser, maxItemsPerPartition uint32, metadata []byte, snapshotMeta cloudmigration.CloudMigrationSnapshot) error {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.buildSnapshot")
defer span.End()
// TODO -- make sure we can only build one snapshot at a time // TODO -- make sure we can only build one snapshot at a time
s.buildSnapshotMutex.Lock() s.buildSnapshotMutex.Lock()
defer s.buildSnapshotMutex.Unlock() defer s.buildSnapshotMutex.Unlock()
@@ -289,20 +320,21 @@ func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedIn
Data: item.Data, Data: item.Data,
}) })
parentName := ""
if _, exists := migrationData.ItemParentNames[item.Type]; exists {
parentName = migrationData.ItemParentNames[item.Type][item.RefID]
}
localSnapshotResource[i] = cloudmigration.CloudMigrationResource{ localSnapshotResource[i] = cloudmigration.CloudMigrationResource{
Name: item.Name, Name: item.Name,
Type: item.Type, Type: item.Type,
RefID: item.RefID, RefID: item.RefID,
Status: cloudmigration.ItemStatusPending, Status: cloudmigration.ItemStatusPending,
ParentName: parentName,
} }
} }
for _, resourceType := range []cloudmigration.MigrateDataType{ for _, resourceType := range currentMigrationTypes {
cloudmigration.DatasourceDataType,
cloudmigration.FolderDataType,
cloudmigration.LibraryElementDataType,
cloudmigration.DashboardDataType,
} {
for chunk := range slices.Chunk(resourcesGroupedByType[resourceType], int(maxItemsPerPartition)) { for chunk := range slices.Chunk(resourcesGroupedByType[resourceType], int(maxItemsPerPartition)) {
if err := snapshotWriter.Write(string(resourceType), chunk); err != nil { if err := snapshotWriter.Write(string(resourceType), chunk); err != nil {
return fmt.Errorf("writing resources to snapshot writer: resourceType=%s %w", resourceType, err) return fmt.Errorf("writing resources to snapshot writer: resourceType=%s %w", resourceType, err)
@@ -339,6 +371,9 @@ func (s *Service) buildSnapshot(ctx context.Context, signedInUser *user.SignedIn
// asynchronous process for and updating the snapshot status // asynchronous process for and updating the snapshot status
func (s *Service) uploadSnapshot(ctx context.Context, session *cloudmigration.CloudMigrationSession, snapshotMeta *cloudmigration.CloudMigrationSnapshot, uploadUrl string) (err error) { func (s *Service) uploadSnapshot(ctx context.Context, session *cloudmigration.CloudMigrationSession, snapshotMeta *cloudmigration.CloudMigrationSnapshot, uploadUrl string) (err error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.uploadSnapshot")
defer span.End()
// TODO -- make sure we can only upload one snapshot at a time // TODO -- make sure we can only upload one snapshot at a time
s.buildSnapshotMutex.Lock() s.buildSnapshotMutex.Lock()
defer s.buildSnapshotMutex.Unlock() defer s.buildSnapshotMutex.Unlock()
@@ -361,37 +396,61 @@ func (s *Service) uploadSnapshot(ctx context.Context, session *cloudmigration.Cl
} }
}() }()
_, readIndexSpan := s.tracer.Start(ctx, "CloudMigrationService.uploadSnapshot.readIndex")
index, err := snapshot.ReadIndex(indexFile) index, err := snapshot.ReadIndex(indexFile)
if err != nil { if err != nil {
readIndexSpan.SetStatus(codes.Error, "reading index from file")
readIndexSpan.RecordError(err)
readIndexSpan.End()
return fmt.Errorf("reading index from file: %w", err) return fmt.Errorf("reading index from file: %w", err)
} }
readIndexSpan.End()
s.log.Debug(fmt.Sprintf("uploadSnapshot: read index file in %d ms", time.Since(start).Milliseconds())) s.log.Debug(fmt.Sprintf("uploadSnapshot: read index file in %d ms", time.Since(start).Milliseconds()))
uploadCtx, uploadSpan := s.tracer.Start(ctx, "CloudMigrationService.uploadSnapshot.uploadDataFiles")
// Upload the data files. // Upload the data files.
for _, fileNames := range index.Items { for _, fileNames := range index.Items {
for _, fileName := range fileNames { for _, fileName := range fileNames {
filePath := filepath.Join(snapshotMeta.LocalDir, fileName) filePath := filepath.Join(snapshotMeta.LocalDir, fileName)
key := fmt.Sprintf("%d/snapshots/%s/%s", session.StackID, snapshotMeta.GMSSnapshotUID, fileName) key := fmt.Sprintf("%d/snapshots/%s/%s", session.StackID, snapshotMeta.GMSSnapshotUID, fileName)
if err := s.uploadUsingPresignedURL(ctx, uploadUrl, key, filePath); err != nil { if err := s.uploadUsingPresignedURL(uploadCtx, uploadUrl, key, filePath); err != nil {
uploadSpan.SetStatus(codes.Error, "uploading snapshot data file using presigned url")
uploadSpan.RecordError(err)
uploadSpan.End()
return fmt.Errorf("uploading snapshot file using presigned url: %w", err) return fmt.Errorf("uploading snapshot file using presigned url: %w", err)
} }
s.log.Debug(fmt.Sprintf("uploadSnapshot: uploaded %s in %d ms", fileName, time.Since(start).Milliseconds())) s.log.Debug(fmt.Sprintf("uploadSnapshot: uploaded %s in %d ms", fileName, time.Since(start).Milliseconds()))
} }
} }
uploadSpan.End()
s.log.Debug(fmt.Sprintf("uploadSnapshot: uploaded all data files in %d ms", time.Since(start).Milliseconds())) s.log.Debug(fmt.Sprintf("uploadSnapshot: uploaded all data files in %d ms", time.Since(start).Milliseconds()))
uploadCtx, uploadSpan = s.tracer.Start(ctx, "CloudMigrationService.uploadSnapshot.uploadIndex")
// Upload the index file. Must be done after uploading the data files. // Upload the index file. Must be done after uploading the data files.
key := fmt.Sprintf("%d/snapshots/%s/%s", session.StackID, snapshotMeta.GMSSnapshotUID, "index.json") key := fmt.Sprintf("%d/snapshots/%s/%s", session.StackID, snapshotMeta.GMSSnapshotUID, "index.json")
if _, err := indexFile.Seek(0, 0); err != nil { if _, err := indexFile.Seek(0, 0); err != nil {
uploadSpan.SetStatus(codes.Error, "seeking to beginning of index file")
uploadSpan.RecordError(err)
uploadSpan.End()
return fmt.Errorf("seeking to beginning of index file: %w", err) return fmt.Errorf("seeking to beginning of index file: %w", err)
} }
if err := s.objectStorage.PresignedURLUpload(ctx, uploadUrl, key, indexFile); err != nil { if err := s.objectStorage.PresignedURLUpload(uploadCtx, uploadUrl, key, indexFile); err != nil {
uploadSpan.SetStatus(codes.Error, "uploading index file using presigned url")
uploadSpan.RecordError(err)
uploadSpan.End()
return fmt.Errorf("uploading file using presigned url: %w", err) return fmt.Errorf("uploading file using presigned url: %w", err)
} }
uploadSpan.End()
s.log.Debug(fmt.Sprintf("uploadSnapshot: uploaded index file in %d ms", time.Since(start).Milliseconds())) s.log.Debug(fmt.Sprintf("uploadSnapshot: uploaded index file in %d ms", time.Since(start).Milliseconds()))
s.log.Info("successfully uploaded snapshot", "snapshotUid", snapshotMeta.UID, "cloud_snapshotUid", snapshotMeta.GMSSnapshotUID) s.log.Info("successfully uploaded snapshot", "snapshotUid", snapshotMeta.UID, "cloud_snapshotUid", snapshotMeta.GMSSnapshotUID)
@@ -408,6 +467,9 @@ func (s *Service) uploadSnapshot(ctx context.Context, session *cloudmigration.Cl
} }
func (s *Service) uploadUsingPresignedURL(ctx context.Context, uploadURL, key string, filePath string) (err error) { func (s *Service) uploadUsingPresignedURL(ctx context.Context, uploadURL, key string, filePath string) (err error) {
ctx, span := s.tracer.Start(ctx, "CloudMigrationService.uploadUsingPresignedURL")
defer span.End()
// The directory that contains the file can set in the configuration, therefore the directory can be any directory. // The directory that contains the file can set in the configuration, therefore the directory can be any directory.
// nolint:gosec // nolint:gosec
file, err := os.Open(filePath) file, err := os.Open(filePath)
@@ -486,3 +548,74 @@ func sortFolders(input []folder.CreateFolderCommand) []folder.CreateFolderComman
return input return input
} }
// getFolderNamesForFolderUIDs queries the folders service to obtain folder names for a list of folderUIDs
func (s *Service) getFolderNamesForFolderUIDs(ctx context.Context, signedInUser *user.SignedInUser, folderUIDs []string) (map[string](string), error) {
folders, err := s.folderService.GetFolders(ctx, folder.GetFoldersQuery{
UIDs: folderUIDs,
SignedInUser: signedInUser,
WithFullpathUIDs: true,
})
if err != nil {
s.log.Error("Failed to obtain folders from folder UIDs", "err", err)
return nil, err
}
folderUIDsToNames := make(map[string](string), len(folderUIDs))
for _, folderUID := range folderUIDs {
folderUIDsToNames[folderUID] = ""
}
for _, f := range folders {
folderUIDsToNames[f.UID] = f.Title
}
return folderUIDsToNames, nil
}
// getParentNames finds the parent names for resources and returns a map of data type: {data UID : parentName}
// for dashboards, folders and library elements - the parent is the parent folder
func (s *Service) getParentNames(ctx context.Context, signedInUser *user.SignedInUser, dashboards []dashboards.Dashboard, folders []folder.CreateFolderCommand, libraryElements []libraryElement) (map[cloudmigration.MigrateDataType]map[string](string), error) {
parentNamesByType := make(map[cloudmigration.MigrateDataType]map[string](string))
for _, dataType := range currentMigrationTypes {
parentNamesByType[dataType] = make(map[string]string)
}
// Obtain list of unique folderUIDs
parentFolderUIDsSet := make(map[string]struct{}, len(dashboards)+len(folders)+len(libraryElements))
for _, dashboard := range dashboards {
parentFolderUIDsSet[dashboard.FolderUID] = struct{}{}
}
for _, f := range folders {
parentFolderUIDsSet[f.ParentUID] = struct{}{}
}
for _, libraryElement := range libraryElements {
if libraryElement.FolderUID != nil {
parentFolderUIDsSet[*libraryElement.FolderUID] = struct{}{}
}
}
parentFolderUIDsSlice := make([]string, 0, len(parentFolderUIDsSet))
for parentFolderUID := range parentFolderUIDsSet {
parentFolderUIDsSlice = append(parentFolderUIDsSlice, parentFolderUID)
}
// Obtain folder names given a list of folderUIDs
foldersUIDsToFolderName, err := s.getFolderNamesForFolderUIDs(ctx, signedInUser, parentFolderUIDsSlice)
if err != nil {
s.log.Error("Failed to get parent folder names from folder UIDs", "err", err)
return parentNamesByType, err
}
// Prepare map of {data type: {data UID : parentName}}
for _, dashboard := range dashboards {
parentNamesByType[cloudmigration.DashboardDataType][dashboard.UID] = foldersUIDsToFolderName[dashboard.FolderUID]
}
for _, f := range folders {
parentNamesByType[cloudmigration.FolderDataType][f.UID] = foldersUIDsToFolderName[f.ParentUID]
}
for _, libraryElement := range libraryElements {
if libraryElement.FolderUID != nil {
parentNamesByType[cloudmigration.LibraryElementDataType][libraryElement.UID] = foldersUIDsToFolderName[*libraryElement.FolderUID]
}
}
return parentNamesByType, err
}
@@ -18,19 +18,21 @@ import (
) )
// NewGMSClient returns an implementation of Client that queries GrafanaMigrationService // NewGMSClient returns an implementation of Client that queries GrafanaMigrationService
func NewGMSClient(cfg *setting.Cfg) (Client, error) { func NewGMSClient(cfg *setting.Cfg, httpClient *http.Client) (Client, error) {
if cfg.CloudMigration.GMSDomain == "" { if cfg.CloudMigration.GMSDomain == "" {
return nil, fmt.Errorf("missing GMS domain") return nil, fmt.Errorf("missing GMS domain")
} }
return &gmsClientImpl{ return &gmsClientImpl{
cfg: cfg, cfg: cfg,
log: log.New(logPrefix), log: log.New(logPrefix),
httpClient: httpClient,
}, nil }, nil
} }
type gmsClientImpl struct { type gmsClientImpl struct {
cfg *setting.Cfg cfg *setting.Cfg
log *log.ConcreteLogger log *log.ConcreteLogger
httpClient *http.Client
getStatusMux sync.Mutex getStatusMux sync.Mutex
getStatusLastQueried time.Time getStatusLastQueried time.Time
@@ -40,8 +42,11 @@ func (c *gmsClientImpl) ValidateKey(ctx context.Context, cm cloudmigration.Cloud
// TODO: there is a lot of boilerplate code in these methods, we should consolidate them when we have a gardening period // TODO: there is a lot of boilerplate code in these methods, we should consolidate them when we have a gardening period
path := fmt.Sprintf("%s/api/v1/validate-key", c.buildBasePath(cm.ClusterSlug)) path := fmt.Sprintf("%s/api/v1/validate-key", c.buildBasePath(cm.ClusterSlug))
ctx, cancel := context.WithTimeout(ctx, c.cfg.CloudMigration.GMSValidateKeyTimeout)
defer cancel()
// validation is an empty POST to GMS with the authorization header included // validation is an empty POST to GMS with the authorization header included
req, err := http.NewRequest("POST", path, bytes.NewReader(nil)) req, err := http.NewRequestWithContext(ctx, "POST", path, bytes.NewReader(nil))
if err != nil { if err != nil {
c.log.Error("error creating http request for token validation", "err", err.Error()) c.log.Error("error creating http request for token validation", "err", err.Error())
return fmt.Errorf("http request error: %w", err) return fmt.Errorf("http request error: %w", err)
@@ -49,10 +54,7 @@ func (c *gmsClientImpl) ValidateKey(ctx context.Context, cm cloudmigration.Cloud
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", cm.StackID, cm.AuthToken)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", cm.StackID, cm.AuthToken))
client := &http.Client{ resp, err := c.httpClient.Do(req)
Timeout: c.cfg.CloudMigration.GMSValidateKeyTimeout,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
c.log.Error("error sending http request for token validation", "err", err.Error()) c.log.Error("error sending http request for token validation", "err", err.Error())
return fmt.Errorf("http request error: %w", err) return fmt.Errorf("http request error: %w", err)
@@ -74,8 +76,11 @@ func (c *gmsClientImpl) ValidateKey(ctx context.Context, cm cloudmigration.Cloud
func (c *gmsClientImpl) StartSnapshot(ctx context.Context, session cloudmigration.CloudMigrationSession) (out *cloudmigration.StartSnapshotResponse, err error) { func (c *gmsClientImpl) StartSnapshot(ctx context.Context, session cloudmigration.CloudMigrationSession) (out *cloudmigration.StartSnapshotResponse, err error) {
path := fmt.Sprintf("%s/api/v1/start-snapshot", c.buildBasePath(session.ClusterSlug)) path := fmt.Sprintf("%s/api/v1/start-snapshot", c.buildBasePath(session.ClusterSlug))
ctx, cancel := context.WithTimeout(ctx, c.cfg.CloudMigration.GMSStartSnapshotTimeout)
defer cancel()
// Send the request to cms with the associated auth token // Send the request to cms with the associated auth token
req, err := http.NewRequest(http.MethodPost, path, nil) req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, nil)
if err != nil { if err != nil {
c.log.Error("error creating http request to start snapshot", "err", err.Error()) c.log.Error("error creating http request to start snapshot", "err", err.Error())
return nil, fmt.Errorf("http request error: %w", err) return nil, fmt.Errorf("http request error: %w", err)
@@ -83,10 +88,7 @@ func (c *gmsClientImpl) StartSnapshot(ctx context.Context, session cloudmigratio
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", session.StackID, session.AuthToken)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", session.StackID, session.AuthToken))
client := &http.Client{ resp, err := c.httpClient.Do(req)
Timeout: c.cfg.CloudMigration.GMSStartSnapshotTimeout,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
c.log.Error("error sending http request to start snapshot", "err", err.Error()) c.log.Error("error sending http request to start snapshot", "err", err.Error())
return nil, fmt.Errorf("http request error: %w", err) return nil, fmt.Errorf("http request error: %w", err)
@@ -119,8 +121,11 @@ func (c *gmsClientImpl) GetSnapshotStatus(ctx context.Context, session cloudmigr
path := fmt.Sprintf("%s/api/v1/snapshots/%s/status?offset=%d", c.buildBasePath(session.ClusterSlug), snapshot.GMSSnapshotUID, offset) path := fmt.Sprintf("%s/api/v1/snapshots/%s/status?offset=%d", c.buildBasePath(session.ClusterSlug), snapshot.GMSSnapshotUID, offset)
ctx, cancel := context.WithTimeout(ctx, c.cfg.CloudMigration.GMSGetSnapshotStatusTimeout)
defer cancel()
// Send the request to gms with the associated auth token // Send the request to gms with the associated auth token
req, err := http.NewRequest(http.MethodGet, path, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, path, nil)
if err != nil { if err != nil {
c.log.Error("error creating http request to get snapshot status", "err", err.Error()) c.log.Error("error creating http request to get snapshot status", "err", err.Error())
return nil, fmt.Errorf("http request error: %w", err) return nil, fmt.Errorf("http request error: %w", err)
@@ -128,11 +133,8 @@ func (c *gmsClientImpl) GetSnapshotStatus(ctx context.Context, session cloudmigr
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", session.StackID, session.AuthToken)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", session.StackID, session.AuthToken))
client := &http.Client{
Timeout: c.cfg.CloudMigration.GMSGetSnapshotStatusTimeout,
}
c.getStatusLastQueried = time.Now() c.getStatusLastQueried = time.Now()
resp, err := client.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
c.log.Error("error sending http request to get snapshot status", "err", err.Error()) c.log.Error("error sending http request to get snapshot status", "err", err.Error())
return nil, fmt.Errorf("http request error: %w", err) return nil, fmt.Errorf("http request error: %w", err)
@@ -163,8 +165,11 @@ func (c *gmsClientImpl) GetSnapshotStatus(ctx context.Context, session cloudmigr
func (c *gmsClientImpl) CreatePresignedUploadUrl(ctx context.Context, session cloudmigration.CloudMigrationSession, snapshot cloudmigration.CloudMigrationSnapshot) (string, error) { func (c *gmsClientImpl) CreatePresignedUploadUrl(ctx context.Context, session cloudmigration.CloudMigrationSession, snapshot cloudmigration.CloudMigrationSnapshot) (string, error) {
path := fmt.Sprintf("%s/api/v1/snapshots/%s/create-upload-url", c.buildBasePath(session.ClusterSlug), snapshot.GMSSnapshotUID) path := fmt.Sprintf("%s/api/v1/snapshots/%s/create-upload-url", c.buildBasePath(session.ClusterSlug), snapshot.GMSSnapshotUID)
ctx, cancel := context.WithTimeout(ctx, c.cfg.CloudMigration.GMSCreateUploadUrlTimeout)
defer cancel()
// Send the request to gms with the associated auth token // Send the request to gms with the associated auth token
req, err := http.NewRequest(http.MethodPost, path, nil) req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, nil)
if err != nil { if err != nil {
c.log.Error("error creating http request to create upload url", "err", err.Error()) c.log.Error("error creating http request to create upload url", "err", err.Error())
return "", fmt.Errorf("http request error: %w", err) return "", fmt.Errorf("http request error: %w", err)
@@ -172,10 +177,7 @@ func (c *gmsClientImpl) CreatePresignedUploadUrl(ctx context.Context, session cl
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", session.StackID, session.AuthToken)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", session.StackID, session.AuthToken))
client := &http.Client{ resp, err := c.httpClient.Do(req)
Timeout: c.cfg.CloudMigration.GMSCreateUploadUrlTimeout,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
c.log.Error("error sending http request to create an upload url", "err", err.Error()) c.log.Error("error sending http request to create an upload url", "err", err.Error())
return "", fmt.Errorf("http request error: %w", err) return "", fmt.Errorf("http request error: %w", err)
@@ -208,6 +210,9 @@ func (c *gmsClientImpl) ReportEvent(ctx context.Context, session cloudmigration.
return return
} }
ctx, cancel := context.WithTimeout(ctx, c.cfg.CloudMigration.GMSReportEventTimeout)
defer cancel()
path := fmt.Sprintf("%s/api/v1/events", c.buildBasePath(session.ClusterSlug)) path := fmt.Sprintf("%s/api/v1/events", c.buildBasePath(session.ClusterSlug))
var buf bytes.Buffer var buf bytes.Buffer
@@ -216,7 +221,7 @@ func (c *gmsClientImpl) ReportEvent(ctx context.Context, session cloudmigration.
return return
} }
// Send the request to gms with the associated auth token // Send the request to gms with the associated auth token
req, err := http.NewRequest(http.MethodPost, path, &buf) req, err := http.NewRequestWithContext(ctx, http.MethodPost, path, &buf)
if err != nil { if err != nil {
c.log.Error("error creating http request to report event", "err", err.Error()) c.log.Error("error creating http request to report event", "err", err.Error())
return return
@@ -224,10 +229,7 @@ func (c *gmsClientImpl) ReportEvent(ctx context.Context, session cloudmigration.
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", session.StackID, session.AuthToken)) req.Header.Set("Authorization", fmt.Sprintf("Bearer %d:%s", session.StackID, session.AuthToken))
client := &http.Client{ resp, err := c.httpClient.Do(req)
Timeout: c.cfg.CloudMigration.GMSReportEventTimeout,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
c.log.Error("error sending http request for report event", "err", err.Error()) c.log.Error("error sending http request for report event", "err", err.Error())
return return
@@ -1,6 +1,7 @@
package gmsclient package gmsclient
import ( import (
"net/http"
"testing" "testing"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
@@ -16,7 +17,9 @@ func Test_buildBasePath(t *testing.T) {
CloudMigration: setting.CloudMigrationSettings{ CloudMigration: setting.CloudMigrationSettings{
GMSDomain: "", GMSDomain: "",
}, },
}) },
http.DefaultClient,
)
require.Error(t, err) require.Error(t, err)
// Domain is required // Domain is required
@@ -24,7 +27,9 @@ func Test_buildBasePath(t *testing.T) {
CloudMigration: setting.CloudMigrationSettings{ CloudMigration: setting.CloudMigrationSettings{
GMSDomain: "non-empty", GMSDomain: "non-empty",
}, },
}) },
http.DefaultClient,
)
require.NoError(t, err) require.NoError(t, err)
client := c.(*gmsClientImpl) client := c.(*gmsClientImpl)
+12 -5
View File
@@ -75,15 +75,21 @@ type CloudMigrationResource struct {
Error string `xorm:"error_string" json:"error"` Error string `xorm:"error_string" json:"error"`
SnapshotUID string `xorm:"snapshot_uid"` SnapshotUID string `xorm:"snapshot_uid"`
ParentName string `xorm:"parent_name" json:"parentName"`
} }
type MigrateDataType string type MigrateDataType string
const ( const (
DashboardDataType MigrateDataType = "DASHBOARD" DashboardDataType MigrateDataType = "DASHBOARD"
DatasourceDataType MigrateDataType = "DATASOURCE" DatasourceDataType MigrateDataType = "DATASOURCE"
FolderDataType MigrateDataType = "FOLDER" FolderDataType MigrateDataType = "FOLDER"
LibraryElementDataType MigrateDataType = "LIBRARY_ELEMENT" LibraryElementDataType MigrateDataType = "LIBRARY_ELEMENT"
AlertRuleType MigrateDataType = "ALERT_RULE"
ContactPointType MigrateDataType = "CONTACT_POINT"
NotificationPolicyType MigrateDataType = "NOTIFICATION_POLICY"
NotificationTemplateType MigrateDataType = "NOTIFICATION_TEMPLATE"
MuteTimingType MigrateDataType = "MUTE_TIMING"
) )
type ItemStatus string type ItemStatus string
@@ -185,7 +191,8 @@ type Base64HGInstance struct {
// GMS domain structs // GMS domain structs
type MigrateDataRequest struct { type MigrateDataRequest struct {
Items []MigrateDataRequestItem Items []MigrateDataRequestItem
ItemParentNames map[MigrateDataType]map[string](string)
} }
type MigrateDataRequestItem struct { type MigrateDataRequestItem struct {
@@ -9,15 +9,26 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
"github.com/grafana/grafana/pkg/infra/tracing"
"go.opentelemetry.io/otel/attribute"
) )
type S3 struct{} type S3 struct {
httpClient *http.Client
tracer tracing.Tracer
}
func NewS3() *S3 { func NewS3(httpClient *http.Client, tracer tracing.Tracer) *S3 {
return &S3{} return &S3{httpClient: httpClient, tracer: tracer}
} }
func (s3 *S3) PresignedURLUpload(ctx context.Context, presignedURL, key string, reader io.Reader) (err error) { func (s3 *S3) PresignedURLUpload(ctx context.Context, presignedURL, key string, reader io.Reader) (err error) {
ctx, span := s3.tracer.Start(ctx, "objectstorage.S3.PresignedURLUpload")
span.SetAttributes(attribute.String("key", key))
defer span.End()
url, err := url.Parse(presignedURL) url, err := url.Parse(presignedURL)
if err != nil { if err != nil {
return fmt.Errorf("parsing presigned url") return fmt.Errorf("parsing presigned url")
@@ -68,13 +79,13 @@ func (s3 *S3) PresignedURLUpload(ctx context.Context, presignedURL, key string,
endpoint := fmt.Sprintf("%s://%s%s", url.Scheme, url.Host, url.Path) endpoint := fmt.Sprintf("%s://%s%s", url.Scheme, url.Host, url.Path)
request, err := http.NewRequest(http.MethodPost, endpoint, buffer) request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, buffer)
if err != nil { if err != nil {
return fmt.Errorf("creating http request: %w", err) return fmt.Errorf("creating http request: %w", err)
} }
request.Header.Set("Content-Type", writer.FormDataContentType()) request.Header.Set("Content-Type", writer.FormDataContentType())
httpClient := http.Client{}
response, err := httpClient.Do(request) response, err := s3.httpClient.Do(request)
if err != nil { if err != nil {
return fmt.Errorf("sending http request: %w", err) return fmt.Errorf("sending http request: %w", err)
} }
@@ -0,0 +1,11 @@
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
}
@@ -0,0 +1,36 @@
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))
})
}
@@ -255,7 +255,7 @@ func (s *Service) AddDataSource(ctx context.Context, cmd *datasources.AddDataSou
} }
var dataSource *datasources.DataSource var dataSource *datasources.DataSource
return dataSource, s.db.InTransaction(ctx, func(ctx context.Context) error { err = s.db.InTransaction(ctx, func(ctx context.Context) error {
var err error var err error
cmd.EncryptedSecureJsonData = make(map[string][]byte) cmd.EncryptedSecureJsonData = make(map[string][]byte)
@@ -293,12 +293,18 @@ func (s *Service) AddDataSource(ctx context.Context, cmd *datasources.AddDataSou
if cmd.UserID != 0 { if cmd.UserID != 0 {
permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{UserID: cmd.UserID, Permission: "Admin"}) permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{UserID: cmd.UserID, Permission: "Admin"})
} }
_, err = s.permissionsService.SetPermissions(ctx, cmd.OrgID, dataSource.UID, permissions...) if _, err = s.permissionsService.SetPermissions(ctx, cmd.OrgID, dataSource.UID, permissions...); err != nil {
return err return err
}
} }
return nil return nil
}) })
if err != nil {
return nil, err
}
return dataSource, nil
} }
// This will valid validate the instance settings return a version that is safe to be saved // This will valid validate the instance settings return a version that is safe to be saved
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -104,6 +105,27 @@ func TestService_AddDataSource(t *testing.T) {
require.EqualError(t, err, "[datasource.urlInvalid] max length is 255") require.EqualError(t, err, "[datasource.urlInvalid] max length is 255")
}) })
t.Run("should fail if the datasource managed permissions fail", func(t *testing.T) {
dsService := initDSService(t)
enableRBACManagedPermissions(t, dsService.cfg)
dsService.permissionsService = &actest.FakePermissionsService{
ExpectedErr: errors.New("failed to set datasource permissions"),
}
dsService.pluginStore = &pluginstore.FakePluginStore{
PluginList: []pluginstore.Plugin{},
}
cmd := &datasources.AddDataSourceCommand{
OrgID: 1,
Type: datasources.DS_TESTDATA,
Name: "test",
}
ds, err := dsService.AddDataSource(context.Background(), cmd)
assert.Nil(t, ds)
assert.ErrorContains(t, err, "failed to set datasource permissions")
})
t.Run("if a plugin has an API version defined (EXPERIMENTAL)", func(t *testing.T) { t.Run("if a plugin has an API version defined (EXPERIMENTAL)", func(t *testing.T) {
t.Run("should success to run admission hooks", func(t *testing.T) { t.Run("should success to run admission hooks", func(t *testing.T) {
dsService := initDSService(t) dsService := initDSService(t)
@@ -580,11 +602,8 @@ func TestService_DeleteDataSource(t *testing.T) {
permissionSvc := acmock.NewMockedPermissionsService() permissionSvc := acmock.NewMockedPermissionsService()
permissionSvc.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil).Once() permissionSvc.On("SetPermissions", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]accesscontrol.ResourcePermission{}, nil).Once()
permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once() permissionSvc.On("DeleteResourcePermissions", mock.Anything, mock.Anything, mock.Anything).Return(nil).Once()
cfg := &setting.Cfg{}
f := ini.Empty() enableRBACManagedPermissions(t, cfg)
f.Section("rbac").Key("resources_with_managed_permissions_on_creation").SetValue("datasource")
cfg, err := setting.NewCfgFromINIFile(f)
require.NoError(t, err)
dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil) dsService, err := ProvideService(sqlStore, secretsService, secretsStore, cfg, featuremgmt.WithFeatures(), acmock.New(), permissionSvc, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{}, nil)
require.NoError(t, err) require.NoError(t, err)
@@ -1521,6 +1540,15 @@ func initDSService(t *testing.T) *Service {
return dsService return dsService
} }
func enableRBACManagedPermissions(t testing.TB, cfg *setting.Cfg) {
t.Helper()
f := ini.Empty()
f.Section("rbac").Key("resources_with_managed_permissions_on_creation").SetValue("datasource")
tempCfg, err := setting.NewCfgFromINIFile(f)
cfg.RBAC = tempCfg.RBAC
require.NoError(t, err)
}
const caCert string = `-----BEGIN CERTIFICATE----- const caCert string = `-----BEGIN CERTIFICATE-----
MIIDATCCAemgAwIBAgIJAMQ5hC3CPDTeMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV MIIDATCCAemgAwIBAgIJAMQ5hC3CPDTeMA0GCSqGSIb3DQEBCwUAMBcxFTATBgNV
BAMMDGNhLWs4cy1zdGhsbTAeFw0xNjEwMjcwODQyMjdaFw00NDAzMTQwODQyMjda BAMMDGNhLWs4cy1zdGhsbTAeFw0xNjEwMjcwODQyMjdaFw00NDAzMTQwODQyMjda
+23 -13
View File
@@ -430,11 +430,10 @@ var (
Owner: grafanaObservabilityLogsSquad, Owner: grafanaObservabilityLogsSquad,
}, },
{ {
Name: "pluginsFrontendSandbox", Name: "pluginsFrontendSandbox",
Description: "Enables the plugins frontend sandbox", Description: "Enables the plugins frontend sandbox",
Stage: FeatureStageExperimental, Stage: FeatureStageExperimental,
FrontendOnly: true, Owner: grafanaPluginsPlatformSquad,
Owner: grafanaPluginsPlatformSquad,
}, },
{ {
Name: "frontendSandboxMonitorOnly", Name: "frontendSandboxMonitorOnly",
@@ -675,9 +674,8 @@ var (
Name: "externalServiceAccounts", Name: "externalServiceAccounts",
Description: "Automatic service account and token setup for plugins", Description: "Automatic service account and token setup for plugins",
HideFromAdminPage: true, HideFromAdminPage: true,
Stage: FeatureStageGeneralAvailability, Stage: FeatureStagePublicPreview,
Owner: identityAccessTeam, Owner: identityAccessTeam,
Expression: "true", // enabled by default
}, },
{ {
Name: "panelMonitoring", Name: "panelMonitoring",
@@ -1063,6 +1061,12 @@ var (
Owner: grafanaOperatorExperienceSquad, Owner: grafanaOperatorExperienceSquad,
Expression: "false", Expression: "false",
}, },
{
Name: "onPremToCloudMigrationsAlerts",
Description: "Enables the migration of alerts and its child resources to your Grafana Cloud stack. Requires `onPremToCloudMigrations` to be enabled in conjunction.",
Stage: FeatureStageExperimental,
Owner: grafanaOperatorExperienceSquad,
},
{ {
Name: "alertingSaveStatePeriodic", Name: "alertingSaveStatePeriodic",
Description: "Writes the state periodically to the database, asynchronous to rule evaluation", Description: "Writes the state periodically to the database, asynchronous to rule evaluation",
@@ -1324,12 +1328,12 @@ var (
Expression: "true", Expression: "true",
}, },
{ {
Name: "ssoSettingsLDAP", Name: "ssoSettingsLDAP",
Description: "Use the new SSO Settings API to configure LDAP", Description: "Use the new SSO Settings API to configure LDAP",
Stage: FeatureStageExperimental, Stage: FeatureStagePublicPreview,
Owner: identityAccessTeam, Owner: identityAccessTeam,
HideFromDocs: true, AllowSelfServe: true,
HideFromAdminPage: true, RequiresRestart: true,
}, },
{ {
Name: "failWrongDSUID", Name: "failWrongDSUID",
@@ -1514,6 +1518,12 @@ var (
HideFromDocs: true, HideFromDocs: true,
HideFromAdminPage: true, HideFromAdminPage: true,
}, },
{
Name: "pluginsSriChecks",
Description: "Enables SRI checks for plugin assets",
Stage: FeatureStageExperimental,
Owner: grafanaPluginsPlatformSquad,
},
} }
) )

Some files were not shown because too many files have changed in this diff Show More