Merge branch 'main' into kristina/config-query-mapping

This commit is contained in:
Kristina Durivage
2025-09-08 08:06:00 -05:00
157 changed files with 4407 additions and 1373 deletions
@@ -684,8 +684,8 @@ VariableSort: "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAs
VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged"
// Determine if the variable shows on dashboard
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing).
VariableHide: *"dontHide" | "hideLabel" | "hideVariable"
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing), `inControlsMenu` (show in a drop-down menu).
VariableHide: *"dontHide" | "hideLabel" | "hideVariable" | "inControlsMenu"
// Determine the origin of the adhoc variable filter
FilterOrigin: "dashboard"
@@ -243,8 +243,8 @@ lineage: schemas: [{
#VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged")
// Determine if the variable shows on dashboard
// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing).
#VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type")
// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu).
#VariableHide: 0 | 1 | 2 | 3 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable|inControlsMenu") @grafana(TSVeneer="type")
// Sort variable options
// Accepted values are:
@@ -243,8 +243,8 @@ lineage: schemas: [{
#VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged")
// Determine if the variable shows on dashboard
// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing).
#VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type")
// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu).
#VariableHide: 0 | 1 | 2 | 3 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable|inControlsMenu") @grafana(TSVeneer="type")
// Sort variable options
// Accepted values are:
@@ -688,8 +688,8 @@ VariableSort: "disabled" | "alphabeticalAsc" | "alphabeticalDesc" | "numericalAs
VariableRefresh: *"never" | "onDashboardLoad" | "onTimeRangeChanged"
// Determine if the variable shows on dashboard
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing).
VariableHide: *"dontHide" | "hideLabel" | "hideVariable"
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing), `inControlsMenu` (show in a drop-down menu).
VariableHide: *"dontHide" | "hideLabel" | "hideVariable" | "inControlsMenu"
// Determine the origin of the adhoc variable filter
FilterOrigin: "dashboard"
@@ -1282,14 +1282,15 @@ func NewDashboardVariableOption() *DashboardVariableOption {
}
// Determine if the variable shows on dashboard
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing).
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing), `inControlsMenu` (show in a drop-down menu).
// +k8s:openapi-gen=true
type DashboardVariableHide string
const (
DashboardVariableHideDontHide DashboardVariableHide = "dontHide"
DashboardVariableHideHideLabel DashboardVariableHide = "hideLabel"
DashboardVariableHideHideVariable DashboardVariableHide = "hideVariable"
DashboardVariableHideDontHide DashboardVariableHide = "dontHide"
DashboardVariableHideHideLabel DashboardVariableHide = "hideLabel"
DashboardVariableHideHideVariable DashboardVariableHide = "hideVariable"
DashboardVariableHideInControlsMenu DashboardVariableHide = "inControlsMenu"
)
// Options to config when to refresh a variable
+1 -1
View File
@@ -109,7 +109,7 @@ func LoadConfigFromEnv() (*Config, error) {
cfg.KubeConfig = kubeConfig
}
cfg.ZanzanaClient.Address = os.Getenv("ZANZANA_ADDR")
cfg.ZanzanaClient.URL = os.Getenv("ZANZANA_ADDR")
cfg.ZanzanaClient.Token = os.Getenv("ZANZANA_TOKEN")
cfg.ZanzanaClient.TokenExchangeURL = os.Getenv("TOKEN_EXCHANGE_URL")
cfg.ZanzanaClient.ServerCertFile = os.Getenv("ZANZANA_SERVER_CERT_FILE")
-2
View File
@@ -744,8 +744,6 @@ github.com/grafana/grafana-aws-sdk v1.1.0 h1:G0fvwbQmHw14c5RXPd7Gnw9ZQcgzl139LtM
github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8=
github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA=
github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0/go.mod h1:H9sVh9A4yg5egMGZeh0mifxT1Q/uqwKe1LBjBJU6pN8=
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs=
github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ=
github.com/grafana/grafana-plugin-sdk-go v0.278.0 h1:5/rIYparLi02pofdaag8wnjspMMVNCi8cZhC4cdC3Ho=
github.com/grafana/grafana-plugin-sdk-go v0.278.0/go.mod h1:+8NXT/XUJ/89GV6FxGQ366NZ3nU+cAXDMd0OUESF9H4=
github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0=
@@ -1,6 +1,10 @@
package v0alpha1
ServiceAccountSpec: {
disabled: bool |* false
plugin: string
role: OrgRole
title: string
disabled: bool
}
OrgRole: "None" | "Viewer" | "Editor" | "Admin" @cuetsy(kind="enum")
@@ -2,13 +2,27 @@
package v0alpha1
// +k8s:openapi-gen=true
type ServiceAccountOrgRole string
const (
ServiceAccountOrgRoleNone ServiceAccountOrgRole = "None"
ServiceAccountOrgRoleViewer ServiceAccountOrgRole = "Viewer"
ServiceAccountOrgRoleEditor ServiceAccountOrgRole = "Editor"
ServiceAccountOrgRoleAdmin ServiceAccountOrgRole = "Admin"
)
// +k8s:openapi-gen=true
type ServiceAccountSpec struct {
Title string `json:"title"`
Disabled bool `json:"disabled"`
Disabled bool `json:"disabled"`
Plugin string `json:"plugin"`
Role ServiceAccountOrgRole `json:"role"`
Title string `json:"title"`
}
// NewServiceAccountSpec creates a new ServiceAccountSpec object.
func NewServiceAccountSpec() *ServiceAccountSpec {
return &ServiceAccountSpec{}
return &ServiceAccountSpec{
Disabled: false,
}
}
@@ -1877,13 +1877,6 @@ func schema_pkg_apis_iam_v0alpha1_ServiceAccountSpec(ref common.ReferenceCallbac
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"title": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"disabled": {
SchemaProps: spec.SchemaProps{
Default: false,
@@ -1891,8 +1884,29 @@ func schema_pkg_apis_iam_v0alpha1_ServiceAccountSpec(ref common.ReferenceCallbac
Format: "",
},
},
"plugin": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"role": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
"title": {
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
Required: []string{"title", "disabled"},
Required: []string{"disabled", "plugin", "role", "title"},
},
},
}
+4 -3
View File
@@ -6,7 +6,6 @@ import (
"net/http"
"github.com/grafana/authlib/authn"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
utilnet "k8s.io/apimachinery/pkg/util/net"
)
@@ -19,20 +18,22 @@ type tokenExchanger interface {
type RoundTripper struct {
client tokenExchanger
transport http.RoundTripper
audience string
}
// NewRoundTripper constructs a RoundTripper that exchanges the provided token per request
// and forwards the request to the provided base transport.
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper) *RoundTripper {
func NewRoundTripper(tokenExchangeClient tokenExchanger, base http.RoundTripper, audience string) *RoundTripper {
return &RoundTripper{
client: tokenExchangeClient,
transport: base,
audience: audience,
}
}
func (t *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
tokenResponse, err := t.client.Exchange(req.Context(), authn.TokenExchangeRequest{
Audiences: []string{provisioning.GROUP},
Audiences: []string{t.audience},
Namespace: "*",
})
if err != nil {
@@ -34,7 +34,7 @@ func TestRoundTripper_SetsAccessTokenHeader(t *testing.T) {
rr := httptest.NewRecorder()
rr.WriteHeader(http.StatusOK)
return rr.Result(), nil
}))
}), "example-audience")
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil)
resp, err := tr.RoundTrip(req)
@@ -50,7 +50,7 @@ func TestRoundTripper_PropagatesExchangeError(t *testing.T) {
tr := NewRoundTripper(&fakeExchanger{err: io.EOF}, roundTripperFunc(func(_ *http.Request) (*http.Response, error) {
t.Fatal("transport should not be called on exchange error")
return nil, nil
}))
}), "example-audience")
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "http://example", nil)
resp, err := tr.RoundTrip(req)
-2
View File
@@ -1903,8 +1903,6 @@ public_key_retrieval_disabled = false
public_key_retrieval_on_startup = false
# Enter a comma-separated list of plugin identifiers to avoid loading (including core plugins). These plugins will be hidden in the catalog.
disable_plugins =
# Comma separated list of plugin ids for which angular deprecation UI should be disabled
hide_angular_deprecation =
# Comma separated list of plugin ids for which environment variables should be forwarded. Used only when feature flag pluginsSkipHostEnvVars is enabled.
forward_host_env_vars =
# Comma separated list of plugin ids to install as part of the startup process.
@@ -255,34 +255,34 @@ After you have provisioned a data source you cannot edit it.
**Example of a Prometheus data source configuration:**
```yaml
apiVersion: 1
```yaml
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://localhost:9090
jsonData:
httpMethod: POST
manageAlerts: true
allowAsRecordingRulesTarget: true
prometheusType: Prometheus
prometheusVersion: 3.3.0
cacheLevel: 'High'
disableRecordingRules: false
timeInterval: 10s # Prometheus scrape interval
incrementalQueryOverlapWindow: 10m
exemplarTraceIdDestinations:
# Field with internal link pointing to data source in Grafana.
# datasourceUid value can be anything, but it should be unique across all defined data source uids.
- datasourceUid: my_jaeger_uid
name: traceID
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://localhost:9090
jsonData:
httpMethod: POST
manageAlerts: true
allowAsRecordingRulesTarget: true
prometheusType: Prometheus
prometheusVersion: 3.3.0
cacheLevel: 'High'
disableRecordingRules: false
timeInterval: 10s # Prometheus scrape interval
incrementalQueryOverlapWindow: 10m
exemplarTraceIdDestinations:
# Field with internal link pointing to data source in Grafana.
# datasourceUid value can be anything, but it should be unique across all defined data source uids.
- datasourceUid: my_jaeger_uid
name: traceID
# Field with external link.
- name: traceID
url: 'http://localhost:3000/explore?orgId=1&left=%5B%22now-1h%22,%22now%22,%22Jaeger%22,%7B%22query%22:%22$${__value.raw}%22%7D%5D'
```
# Field with external link.
- name: traceID
url: 'http://localhost:3000/explore?orgId=1&left=%5B%22now-1h%22,%22now%22,%22Jaeger%22,%7B%22query%22:%22$${__value.raw}%22%7D%5D'
```
## Azure authentication settings
-2
View File
@@ -98,7 +98,6 @@ require (
github.com/grafana/grafana-api-golang-client v0.27.0 // @grafana/alerting-backend
github.com/grafana/grafana-app-sdk v0.40.3 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana-app-sdk/logging v0.40.3 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana-app-sdk/plugin v0.40.3 // @grafana/grafana-app-platform-squad
github.com/grafana/grafana-aws-sdk v1.1.0 // @grafana/aws-datasources
github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 // @grafana/partner-datasources
github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad
@@ -233,7 +232,6 @@ require (
require (
github.com/grafana/grafana/apps/advisor v0.0.0 // @grafana/plugins-platform-backend
github.com/grafana/grafana/apps/alerting/alertenrichment v0.0.0 // @grafana/alerting-backend
github.com/grafana/grafana/apps/alerting/notifications v0.0.0 // @grafana/alerting-backend
github.com/grafana/grafana/apps/dashboard v0.0.0 // @grafana/grafana-app-platform-squad @grafana/dashboards-squad
github.com/grafana/grafana/apps/folder v0.0.0 // @grafana/grafana-search-and-storage
-2
View File
@@ -1605,8 +1605,6 @@ github.com/grafana/grafana-app-sdk v0.40.3 h1:JFo7uAfbAJUfZ9neD7/4sODKm1xgu9zhck
github.com/grafana/grafana-app-sdk v0.40.3/go.mod h1:j0KzHo3Sa6kd+lnwSScBNoV9Vobkg/YY9HtEjxpyPrk=
github.com/grafana/grafana-app-sdk/logging v0.40.3 h1:2VXsXXEQiqAavRP8wusRDB6rDqf5lufP7A6NfjELqPE=
github.com/grafana/grafana-app-sdk/logging v0.40.3/go.mod h1:otUD9XpJD7A5sCLb8mcs9hIXGdeV6lnhzVwe747g4RU=
github.com/grafana/grafana-app-sdk/plugin v0.40.3 h1:uH0oFZnYOUL+OXcyhd5NVYwoM+Wa0WUXvZ2Om1M91r0=
github.com/grafana/grafana-app-sdk/plugin v0.40.3/go.mod h1:+ylwE0P8WgPu5zURK5aDnVJpwRpuK3573rwrVV28qzQ=
github.com/grafana/grafana-aws-sdk v1.1.0 h1:G0fvwbQmHw14c5RXPd7Gnw9ZQcgzl139LtMDoe0KhmE=
github.com/grafana/grafana-aws-sdk v1.1.0/go.mod h1:7e+47EdHynteYWGoT5Ere9KeOXQObsk8F0vkOLQ1tz8=
github.com/grafana/grafana-azure-sdk-go/v2 v2.2.0 h1:0TYrkzAc3u0HX+9GK86cGrLTUAcmQfl3/LEB3tL+SOA=
+2 -2
View File
@@ -239,8 +239,8 @@ lineage: schemas: [{
#VariableRefresh: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="never|onDashboardLoad|onTimeRangeChanged")
// Determine if the variable shows on dashboard
// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing).
#VariableHide: 0 | 1 | 2 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable") @grafana(TSVeneer="type")
// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu).
#VariableHide: 0 | 1 | 2 | 3 @cuetsy(kind="enum",memberNames="dontHide|hideLabel|hideVariable|inControlsMenu") @grafana(TSVeneer="type")
// Sort variable options
// Accepted values are:
+3 -3
View File
@@ -264,7 +264,7 @@
"@emotion/css": "11.13.5",
"@emotion/react": "11.14.0",
"@fingerprintjs/fingerprintjs": "^3.4.2",
"@floating-ui/react": "0.27.15",
"@floating-ui/react": "0.27.16",
"@formatjs/intl-durationformat": "^0.7.0",
"@glideapps/glide-data-grid": "^6.0.0",
"@grafana/alerting": "workspace:*",
@@ -286,8 +286,8 @@
"@grafana/plugin-ui": "^0.10.10",
"@grafana/prometheus": "workspace:*",
"@grafana/runtime": "workspace:*",
"@grafana/scenes": "6.33.0",
"@grafana/scenes-react": "6.33.0",
"@grafana/scenes": "6.34.0",
"@grafana/scenes-react": "6.34.0",
"@grafana/schema": "workspace:*",
"@grafana/sql": "workspace:*",
"@grafana/ui": "workspace:*",
@@ -1,7 +1,8 @@
import { FieldType } from '../types/dataFrame';
import { TimeRange } from '../types/time';
import { createDataFrame, toDataFrame } from './processDataFrame';
import { anySeriesWithTimeField, addRow } from './utils';
import { anySeriesWithTimeField, addRow, alignTimeRangeCompareData, shouldAlignTimeCompare } from './utils';
describe('anySeriesWithTimeField', () => {
describe('single frame', () => {
@@ -104,3 +105,287 @@ describe('addRow', () => {
expect(frame.length).toBe(2);
});
});
describe('alignTimeRangeCompareData', () => {
const ONE_DAY_MS = 24 * 60 * 60 * 1000; // 86400000ms
const ONE_WEEK_MS = 7 * ONE_DAY_MS; // 604800000ms
it('should align time field values with positive diff (1 day)', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000] },
{ name: 'value', type: FieldType.number, values: [10, 20, 30] },
],
});
alignTimeRangeCompareData(frame, ONE_DAY_MS);
expect(frame.fields[0].values).toEqual([ONE_DAY_MS + 1000, ONE_DAY_MS + 2000, ONE_DAY_MS + 3000]);
expect(frame.fields[1].values).toEqual([10, 20, 30]); // non-time fields unchanged
});
it('should align time field values with negative diff (1 week)', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000, 3000] },
{ name: 'value', type: FieldType.number, values: [10, 20, 30] },
],
});
alignTimeRangeCompareData(frame, -ONE_WEEK_MS);
// When diff is negative, function does v - diff, so v - (-ONE_WEEK_MS) = v + ONE_WEEK_MS
expect(frame.fields[0].values).toEqual([ONE_WEEK_MS + 1000, ONE_WEEK_MS + 2000, ONE_WEEK_MS + 3000]);
});
it('should apply default gray color and timeCompare config', () => {
const frame = toDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [1000, 2000] },
{ name: 'value', type: FieldType.number, values: [10, 20] },
],
});
alignTimeRangeCompareData(frame, ONE_DAY_MS);
frame.fields.forEach((field) => {
expect(field.config.color?.fixedColor).toBe('gray');
expect(field.config.custom?.timeCompare).toEqual({
diffMs: ONE_DAY_MS,
isTimeShiftQuery: true,
});
});
});
it('should apply custom color when provided', () => {
const frame = toDataFrame({
fields: [{ name: 'value', type: FieldType.number, values: [10, 20] }],
});
alignTimeRangeCompareData(frame, ONE_DAY_MS, 'red');
expect(frame.fields[0].config.color?.fixedColor).toBe('red');
});
it('should preserve existing config when merging', () => {
const frame = toDataFrame({
fields: [
{
name: 'value',
type: FieldType.number,
values: [10, 20],
config: {
displayName: 'My Display Name',
custom: { existingProperty: 'existingValue' },
},
},
],
});
alignTimeRangeCompareData(frame, ONE_WEEK_MS);
expect(frame.fields[0].config.displayName).toBe('My Display Name');
expect(frame.fields[0].config.custom?.existingProperty).toBe('existingValue');
expect(frame.fields[0].config.custom?.timeCompare?.diffMs).toBe(ONE_WEEK_MS);
});
});
describe('shouldAlignTimeCompare', () => {
const TIME_VALUES_A = [1000, 2000, 3000];
const TIME_VALUES_B = [5000, 6000, 7000];
const ORIGINAL_VALUES = [10, 20, 30];
const COMPARE_VALUES = [15, 25, 35];
const mockTimeRange: TimeRange = {
from: { valueOf: () => 4000 },
to: { valueOf: () => 8000 },
raw: { from: 'now-1h', to: 'now' },
} as TimeRange;
it('should return true when compare first time is before time range', () => {
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: COMPARE_VALUES },
],
meta: {
timeCompare: {
isTimeShiftQuery: true,
diffMs: 86400000,
},
},
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(true);
});
it('should return false when compare first time is after time range', () => {
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_B },
{ name: 'value', type: FieldType.number, values: COMPARE_VALUES },
],
meta: {
timeCompare: {
isTimeShiftQuery: true,
diffMs: 86400000,
},
},
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when compare frame refId does not end with -compare', () => {
const compareFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const allFrames = [compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when original frame is not found', () => {
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const allFrames = [compareFrame]; // No original frame with refId 'A'
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when compare frame has no time field', () => {
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [{ name: 'value', type: FieldType.number, values: COMPARE_VALUES }],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when original frame has no time field', () => {
const originalFrame = toDataFrame({
refId: 'A',
fields: [{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES }],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_VALUES_A },
{ name: 'value', type: FieldType.number, values: COMPARE_VALUES },
],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should return false when time fields have empty values', () => {
const EMPTY_VALUES: number[] = [];
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: EMPTY_VALUES },
{ name: 'value', type: FieldType.number, values: EMPTY_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: EMPTY_VALUES },
{ name: 'value', type: FieldType.number, values: EMPTY_VALUES },
],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
it('should handle null values and return true when first non-null time is before range', () => {
const TIME_WITH_NULLS = [null, ...TIME_VALUES_A];
const ORIGINAL_WITH_NULLS = [null, ...ORIGINAL_VALUES];
const COMPARE_WITH_NULLS = [null, ...COMPARE_VALUES];
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_WITH_NULLS },
{ name: 'value', type: FieldType.number, values: ORIGINAL_WITH_NULLS },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: TIME_WITH_NULLS },
{ name: 'value', type: FieldType.number, values: COMPARE_WITH_NULLS },
],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(true);
});
it('should return false when all time values are null', () => {
const ALL_NULL_TIMES = [null, null, null];
const originalFrame = toDataFrame({
refId: 'A',
fields: [
{ name: 'time', type: FieldType.time, values: ALL_NULL_TIMES },
{ name: 'value', type: FieldType.number, values: ORIGINAL_VALUES },
],
});
const compareFrame = toDataFrame({
refId: 'A-compare',
fields: [
{ name: 'time', type: FieldType.time, values: ALL_NULL_TIMES },
{ name: 'value', type: FieldType.number, values: COMPARE_VALUES },
],
});
const allFrames = [originalFrame, compareFrame];
expect(shouldAlignTimeCompare(compareFrame, allFrames, mockTimeRange)).toBe(false);
});
});
@@ -1,4 +1,5 @@
import { DataFrame, Field, FieldType } from '../types/dataFrame';
import { TimeRange } from '../types/time';
import { getTimeField } from './processDataFrame';
@@ -123,3 +124,79 @@ export function addRow(dataFrame: DataFrame, row: Record<string, unknown> | unkn
// does not need any external updating.
}
}
/**
* Aligns time range comparison data by adjusting timestamps and applying compare-specific styling
* @param series - The DataFrame containing the comparison data
* @param diff - The time difference in milliseconds to align the timestamps
* @param compareColor - Optional color to use for the comparison series (defaults to 'gray')
*/
export function alignTimeRangeCompareData(series: DataFrame, diff: number, compareColor = 'gray') {
series.fields.forEach((field: Field) => {
// Align compare series time stamps with reference series
if (field.type === FieldType.time) {
field.values = field.values.map((v: number) => {
return diff < 0 ? v - diff : v + diff;
});
}
field.config = {
...(field.config ?? {}),
color: {
mode: 'fixed',
fixedColor: compareColor,
},
custom: {
...(field.config?.custom ?? {}),
timeCompare: {
diffMs: diff,
isTimeShiftQuery: true,
},
},
};
});
}
/**
* Checks if a time comparison frame needs alignment based on whether its first time is before the current time range.
* Returns true if the first time in compare is before timeRange.from, indicating it needs shifting.
* @param compareFrame - The frame with time comparison data
* @param allFrames - Array of all frames to find the matching original frame
* @param timeRange - The current panel time range
* @returns true if alignment is needed
*/
export function shouldAlignTimeCompare(compareFrame: DataFrame, allFrames: DataFrame[], timeRange: TimeRange): boolean {
// Find the matching original frame by removing '-compare' from refId
const compareRefId = compareFrame.refId;
if (!compareRefId || !compareRefId.endsWith('-compare')) {
return false;
}
const originalRefId = compareRefId.replace('-compare', '');
const originalFrame = allFrames.find(
(frame) => frame.refId === originalRefId && !frame.meta?.timeCompare?.isTimeShiftQuery
);
if (!originalFrame) {
return false;
}
// Find time fields
const compareTimeField = compareFrame.fields.find((field) => field.type === FieldType.time);
const originalTimeField = originalFrame.fields.find((field) => field.type === FieldType.time);
if (!compareTimeField?.values.length || !originalTimeField?.values.length) {
return false;
}
// Find first non-null time value from each frame
const compareFirstTime = compareTimeField.values.find((value) => value != null);
const originalFirstTime = originalTimeField.values.find((value) => value != null);
if (compareFirstTime == null || originalFirstTime == null) {
return false;
}
// Check if first non-null time value is before timeRange.from
return compareFirstTime < timeRange.from.valueOf();
}
+2
View File
@@ -50,6 +50,8 @@ export {
isTimeSeriesField,
getRowUniqueId,
addRow,
alignTimeRangeCompareData,
shouldAlignTimeCompare,
} from './dataframe/utils';
export {
StreamingDataFrame,
@@ -229,6 +229,7 @@ export type CentralAlertHistorySceneV1Props = {
defaultLabelsFilter?: string;
defaultTimeRange?: { from: string; to: string };
hideFilters?: boolean;
hideAlertRuleColumn?: boolean;
};
export type PluginExtensionQueryEditorRowAdaptiveTelemetryV1Context = {
@@ -47,6 +47,7 @@ export enum VariableHide {
dontHide,
hideLabel,
hideVariable,
inControlsMenu,
}
export interface AdHocVariableFilter {
@@ -108,6 +108,14 @@ export const versionedPages = {
'10.0.0': 'data-testid Confirm Modal Danger Button',
[MIN_GRAFANA_VERSION]: 'Confirm Modal Danger Button',
},
input: {
'12.2.0': 'data-testid Confirm Modal Input',
},
},
SecretsManagement: {
SecretForm: {
'12.2.0': 'data-testid Secret Form',
},
},
AddDashboard: {
url: {
+1 -1
View File
@@ -40,7 +40,7 @@
},
"dependencies": {
"@emotion/css": "11.13.5",
"@floating-ui/react": "0.27.15",
"@floating-ui/react": "0.27.16",
"@grafana/data": "12.2.0-pre",
"@grafana/e2e-selectors": "12.2.0-pre",
"@grafana/i18n": "12.2.0-pre",
@@ -246,12 +246,13 @@ export enum VariableRefresh {
/**
* Determine if the variable shows on dashboard
* Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing).
* Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu).
*/
export enum VariableHide {
dontHide = 0,
hideLabel = 1,
hideVariable = 2,
inControlsMenu = 3,
}
/**
@@ -1039,8 +1039,8 @@ export const defaultVariableOption = (): VariableOption => ({
});
// Determine if the variable shows on dashboard
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing).
export type VariableHide = "dontHide" | "hideLabel" | "hideVariable";
// Accepted values are `dontHide` (show label and value), `hideLabel` (show value only), `hideVariable` (show nothing), `inControlsMenu` (show in a drop-down menu).
export type VariableHide = "dontHide" | "hideLabel" | "hideVariable" | "inControlsMenu";
export const defaultVariableHide = (): VariableHide => ("dontHide");
@@ -18,6 +18,7 @@ export enum VariableHide {
dontHide,
hideLabel,
hideVariable,
inControlsMenu,
}
export interface VariableModel extends Omit<raw.VariableModel, 'datasource'> {
+1 -1
View File
@@ -66,7 +66,7 @@
"@emotion/css": "11.13.5",
"@emotion/react": "11.14.0",
"@emotion/serialize": "1.3.3",
"@floating-ui/react": "0.27.15",
"@floating-ui/react": "0.27.16",
"@grafana/data": "12.2.0-pre",
"@grafana/e2e-selectors": "12.2.0-pre",
"@grafana/faro-web-sdk": "^1.13.2",
@@ -103,7 +103,11 @@ export const ConfirmContent = ({
<div className={styles.confirmationInput}>
<Stack alignItems="flex-start">
<Field disabled={disabled}>
<Input placeholder={placeholder} onChange={onConfirmationTextChange} />
<Input
placeholder={placeholder}
onChange={onConfirmationTextChange}
data-testid={selectors.pages.ConfirmModal.input}
/>
</Field>
</Stack>
</div>
-44
View File
@@ -7,26 +7,6 @@ import (
// Events can be passed to external systems via for example AMQP
// Treat these events as basically DTOs so changes has to be backward compatible
type OrgCreated struct {
Timestamp time.Time `json:"timestamp"`
Id int64 `json:"id"`
Name string `json:"name"`
}
type OrgUpdated struct {
Timestamp time.Time `json:"timestamp"`
Id int64 `json:"id"`
Name string `json:"name"`
}
type UserCreated struct {
Timestamp time.Time `json:"timestamp"`
Id int64 `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
Email string `json:"email"`
}
type SignUpStarted struct {
Timestamp time.Time `json:"timestamp"`
Email string `json:"email"`
@@ -39,14 +19,6 @@ type SignUpCompleted struct {
Email string `json:"email"`
}
type UserUpdated struct {
Timestamp time.Time `json:"timestamp"`
Id int64 `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
Email string `json:"email"`
}
type DataSourceDeleted struct {
Timestamp time.Time `json:"timestamp"`
Name string `json:"name"`
@@ -55,22 +27,6 @@ type DataSourceDeleted struct {
OrgID int64 `json:"org_id"`
}
type DataSourceSecretDeleted struct {
Timestamp time.Time `json:"timestamp"`
Name string `json:"name"`
ID int64 `json:"id"`
UID string `json:"uid"`
OrgID int64 `json:"org_id"`
}
type DataSourceCreated struct {
Timestamp time.Time `json:"timestamp"`
Name string `json:"name"`
ID int64 `json:"id"`
UID string `json:"uid"`
OrgID int64 `json:"org_id"`
}
// FolderFullPathUpdated is emitted when the full path of the folder(s) is updated.
// For example, when the folder is renamed or moved to another folder.
// It does not contain the full path of the folders because calculating
-1
View File
@@ -53,6 +53,5 @@ import (
_ "github.com/grafana/e2e"
_ "github.com/grafana/gofpdf"
_ "github.com/grafana/gomemcache/memcache"
_ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1"
_ "github.com/grafana/tempo/pkg/traceql"
)
+5 -4
View File
@@ -766,13 +766,14 @@ const (
)
// Determine if the variable shows on dashboard
// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing).
// Accepted values are 0 (show label and value), 1 (show value only), 2 (show nothing), 3 (show under the controls dropdown menu).
type VariableHide int64
const (
VariableHideDontHide VariableHide = 0
VariableHideHideLabel VariableHide = 1
VariableHideHideVariable VariableHide = 2
VariableHideDontHide VariableHide = 0
VariableHideHideLabel VariableHide = 1
VariableHideHideVariable VariableHide = 2
VariableHideInControlsMenu VariableHide = 3
)
// Option to be selected in a variable.
+7 -5
View File
@@ -2,11 +2,13 @@ To build the operator, simply run `make build-go`
To run the folder reconciler, you need a `./conf/operator.ini` config file. For example:
```
[iam_folder_reconciler]
folder_app_url = https://host.docker.internal:6446
folder_app_namespace = *
zanzana_address = zanzana.default.svc.cluster.local:50051
[grpc_client_authentication]
token = IamFolderReconcilerToken
token_exchange_url = http://host.docker.internal:8080/v1/sign-access-token
token = ProvisioningAdminToken
[operator]
folder_app_url = https://host.docker.internal:6446
zanzana_url = zanzana.default.svc.cluster.local:50051
tls_insecure = true
```
After that, you can run it using: `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=iam-folder-reconciler ./bin/linux-arm64/grafana server target --config=conf/operator.ini`. Beware that you will also need a TokenExchanger, a Zanzana Server and a Folder app running for the operator to behave.
+85 -58
View File
@@ -2,6 +2,7 @@ package iam
import (
"context"
"crypto/x509"
"errors"
"fmt"
"log/slog"
@@ -10,9 +11,9 @@ import (
"os/signal"
"syscall"
"github.com/grafana/grafana-app-sdk/k8s"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana-app-sdk/operator"
folder "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/apps/iam/pkg/app"
"github.com/grafana/grafana/pkg/server"
"github.com/grafana/grafana/pkg/services/apiserver/standalone"
@@ -22,7 +23,6 @@ import (
"k8s.io/client-go/transport"
"github.com/grafana/authlib/authn"
"github.com/grafana/grafana-app-sdk/plugin/kubeconfig"
utilnet "k8s.io/apimachinery/pkg/util/net"
)
@@ -78,64 +78,63 @@ type iamConfig struct {
AppConfig app.AppConfig
}
const (
ConnTypeGRPC = "grpc"
ConnTypeHTTP = "http"
)
func buildIAMConfigFromSettings(cfg *setting.Cfg) (*iamConfig, error) {
var err error
if cfg == nil {
return nil, fmt.Errorf("no configuration available")
}
iamCfg := iamConfig{}
iamFolderReconcilerSec := cfg.SectionWithEnvOverrides("iam_folder_reconciler")
zanzanaAddress := iamFolderReconcilerSec.Key("zanzana_address").MustString("")
if zanzanaAddress == "" {
return nil, fmt.Errorf("address is required in [iam_folder_reconciler.zanzana] section")
}
iamCfg.AppConfig.ZanzanaClientCfg.Address = zanzanaAddress
tokenExchangeURL := iamFolderReconcilerSec.Key("token_exchange_url").MustString("")
if tokenExchangeURL == "" {
return nil, fmt.Errorf("token_exchange_url is required in [iam_folder_reconciler] section")
}
iamCfg.AppConfig.ZanzanaClientCfg.TokenExchangeURL = tokenExchangeURL
token := iamFolderReconcilerSec.Key("token").MustString("")
gRPCAuth := cfg.SectionWithEnvOverrides("grpc_client_authentication")
token := gRPCAuth.Key("token").String()
if token == "" {
return nil, fmt.Errorf("token is required in [iam_folder_reconciler] section")
return nil, fmt.Errorf("token is required in [grpc_client_authentication] section")
}
iamCfg.AppConfig.ZanzanaClientCfg.Token = token
folderAppURL := iamFolderReconcilerSec.Key("folder_app_url").MustString("")
folderAppNamespace := iamFolderReconcilerSec.Key("folder_app_namespace").MustString("default")
tokenExchangeURL := gRPCAuth.Key("token_exchange_url").String()
if tokenExchangeURL == "" {
return nil, fmt.Errorf("token_exchange_url is required in [grpc_client_authentication] section")
}
iamCfg.AppConfig.ZanzanaClientCfg.TokenExchangeURL = tokenExchangeURL
kubeConfig, err := buildKubeConfigFromFolderAppURL(folderAppURL, tokenExchangeURL, token, folderAppNamespace)
operatorSec := cfg.SectionWithEnvOverrides("operator")
zanzanaURL := operatorSec.Key("zanzana_url").MustString("")
if zanzanaURL == "" {
return nil, fmt.Errorf("zanzana_url is required in [operator] section")
}
iamCfg.AppConfig.ZanzanaClientCfg.URL = zanzanaURL
folderAppURL := operatorSec.Key("folder_app_url").MustString("")
if folderAppURL == "" {
return nil, fmt.Errorf("folder_app_url is required in [operator] section")
}
tlsInsecure := operatorSec.Key("tls_insecure").MustBool(false)
tlsCertFile := operatorSec.Key("tls_cert_file").String()
tlsKeyFile := operatorSec.Key("tls_key_file").String()
tlsCAFile := operatorSec.Key("tls_ca_file").String()
iamCfg.AppConfig.ZanzanaClientCfg.ServerCertFile = tlsCertFile
kubeConfig, err := buildKubeConfigFromFolderAppURL(
folderAppURL,
tokenExchangeURL, token,
tlsInsecure, tlsCertFile, tlsKeyFile, tlsCAFile,
)
if err != nil {
return nil, fmt.Errorf("failed to build kube config: %w", err)
}
iamCfg.RunnerConfig.KubeConfig = kubeConfig.RestConfig
wenhookSection := cfg.SectionWithEnvOverrides("iam_folder_reconciler.webhook_server")
webhookPort := wenhookSection.Key("port").MustInt(8443)
webhookCertPath := wenhookSection.Key("cert_path").MustString("")
webhookKeyPath := wenhookSection.Key("key_path").MustString("")
iamCfg.RunnerConfig.WebhookConfig = operator.RunnerWebhookConfig{
Port: webhookPort,
TLSConfig: k8s.TLSConfig{
CertPath: webhookCertPath,
KeyPath: webhookKeyPath,
},
}
iamCfg.RunnerConfig.KubeConfig = *kubeConfig
return &iamCfg, nil
}
func buildKubeConfigFromFolderAppURL(folderAppURL, exchangeUrl, authToken, namespace string) (*kubeconfig.NamespacedConfig, error) {
func buildKubeConfigFromFolderAppURL(
folderAppURL string,
exchangeUrl, authToken string,
tlsInsecure bool, tlsCertFile, tlsKeyFile, tlsCAFile string,
) (*rest.Config, error) {
tokenExchangeClient, err := authn.NewTokenExchangeClient(authn.TokenExchangeConfig{
TokenExchangeURL: exchangeUrl,
Token: authToken,
@@ -144,24 +143,53 @@ func buildKubeConfigFromFolderAppURL(folderAppURL, exchangeUrl, authToken, names
return nil, fmt.Errorf("failed to create token exchange client: %w", err)
}
return &kubeconfig.NamespacedConfig{
RestConfig: rest.Config{
APIPath: "/apis",
Host: folderAppURL,
WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper {
return &authRoundTripper{
tokenExchangeClient: tokenExchangeClient,
transport: rt,
}
}),
TLSClientConfig: rest.TLSClientConfig{
Insecure: true,
},
},
Namespace: namespace,
tlsConfig, err := buildTLSConfig(tlsInsecure, tlsCertFile, tlsKeyFile, tlsCAFile)
if err != nil {
return nil, fmt.Errorf("failed to build TLS configuration: %w", err)
}
return &rest.Config{
APIPath: "/apis",
Host: folderAppURL,
WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper {
return &authRoundTripper{
tokenExchangeClient: tokenExchangeClient,
transport: rt,
}
}),
TLSClientConfig: tlsConfig,
}, nil
}
func buildTLSConfig(insecure bool, certFile, keyFile, caFile string) (rest.TLSClientConfig, error) {
tlsConfig := rest.TLSClientConfig{
Insecure: insecure,
}
if certFile != "" && keyFile != "" {
tlsConfig.CertFile = certFile
tlsConfig.KeyFile = keyFile
}
if caFile != "" {
// caFile is set in operator.ini file
// nolint:gosec
caCert, err := os.ReadFile(caFile)
if err != nil {
return tlsConfig, fmt.Errorf("failed to read CA certificate file: %w", err)
}
caCertPool := x509.NewCertPool()
if !caCertPool.AppendCertsFromPEM(caCert) {
return tlsConfig, fmt.Errorf("failed to parse CA certificate")
}
tlsConfig.CAData = caCert
}
return tlsConfig, nil
}
type authRoundTripper struct {
tokenExchangeClient *authn.TokenExchangeClient
transport http.RoundTripper
@@ -169,7 +197,7 @@ type authRoundTripper struct {
func (t *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
tokenResponse, err := t.tokenExchangeClient.Exchange(req.Context(), authn.TokenExchangeRequest{
Audiences: []string{"folder.grafana.app"},
Audiences: []string{folder.GROUP},
Namespace: "*",
})
if err != nil {
@@ -178,7 +206,6 @@ func (t *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
// clone the request as RTs are not expected to mutate the passed request
req = utilnet.CloneRequest(req)
req.Header.Set("X-Access-Token", "Bearer "+tokenResponse.Token)
return t.transport.RoundTrip(req)
}
+8 -8
View File
@@ -4,12 +4,6 @@ Git sync has two different controllers: the jobs controller and the repo control
## Jobs Controller
> [!WARNING]
> This controller has current limitations:
>
> - Does not start the ConcurrentJobDriver yet. Notifications are logged but not consumed by workers here.
> - Job processing (claim/renew/update/complete) isn't implemented yet as it requires refactoring of some components.
### Behavior
- Watches provisioning `Jobs` and emits notifications on job creation.
@@ -51,6 +45,7 @@ This binary currently wires informers and emits job-create notifications. In the
- `make build`
2. Ensure the following services are running locally: provisioning API server, secrets service API server, repository controller, unified storage, and auth.
3. Create a operator.ini file:
```
[database]
ensure_default_org_and_user = false
@@ -65,12 +60,15 @@ token_exchange_url = http://localhost:6481/sign/access-token
# Uncomment to enable history cleanup via Loki. First ensure the Provisioning API is configured with Loki for job history (see `createJobHistoryConfigFromSettings` in `pkg/registry/apis/provisioning/register.go`).
# history_expiration = 24h
```
3. Start the controller:
- `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=provisioning-jobs ./bin/darwin-arm64/grafana server target --config=conf/operator.ini`
- `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=provisioning-jobs ./bin/darwin-arm64/grafana server target --config=conf/operator.ini`
#### TLS Configuration Examples
- **Production with proper TLS verification**:
```
[operator]
provisioning_server_url = https://localhost:6446
@@ -83,6 +81,7 @@ token_exchange_url = http://localhost:6481/sign/access-token
```
- **Mutual TLS authentication**:
```
[operator]
provisioning_server_url = https://localhost:6446
@@ -97,6 +96,7 @@ token_exchange_url = http://localhost:6481/sign/access-token
```
- **Development with self-signed certificates (insecure)**:
```
[operator]
provisioning_server_url = https://localhost:6446
@@ -155,4 +155,4 @@ curl -X POST https://localhost:6446/apis/provisioning.grafana.app/v0alpha1/names
This controller is responsible for watching repositories. It will eventually do health checks, queue sync jobs, and create/delete github hooks.
To run locally, run `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=provisioning-repo ./bin/darwin-arm64/grafana server target --config=conf/operator.ini`
To run locally, run `GF_DEFAULT_TARGET=operator GF_OPERATOR_NAME=provisioning-repo ./bin/darwin-arm64/grafana server target --config=conf/operator.ini`
+12 -8
View File
@@ -111,7 +111,7 @@ func setupFromConfig(cfg *setting.Cfg) (controllerCfg *provisioningControllerCon
APIPath: "/apis",
Host: provisioningServerURL,
WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper {
return authrt.NewRoundTripper(tokenExchangeClient, rt)
return authrt.NewRoundTripper(tokenExchangeClient, rt, provisioning.GROUP)
}),
TLSClientConfig: tlsConfig,
}
@@ -145,27 +145,31 @@ func setupFromConfig(cfg *setting.Cfg) (controllerCfg *provisioningControllerCon
}
dashboardsServerURL := operatorSec.Key("dashboards_server_url").String()
if provisioningServerURL == "" {
if dashboardsServerURL == "" {
return nil, fmt.Errorf("dashboards_server_url is required in [operator] section")
}
foldersServerURL := operatorSec.Key("folders_server_url").String()
if provisioningServerURL == "" {
if foldersServerURL == "" {
return nil, fmt.Errorf("folders_server_url is required in [operator] section")
}
apiServerURLs := []string{dashboardsServerURL, foldersServerURL, provisioningServerURL}
configProviders := make([]apiserver.RestConfigProvider, len(apiServerURLs))
apiServerURLs := map[string]string{
resources.DashboardResource.Group: dashboardsServerURL,
resources.FolderResource.Group: foldersServerURL,
provisioning.GROUP: provisioningServerURL,
}
configProviders := make(map[string]apiserver.RestConfigProvider)
for i, url := range apiServerURLs {
for group, url := range apiServerURLs {
config := &rest.Config{
APIPath: "/apis",
Host: url,
WrapTransport: transport.WrapperFunc(func(rt http.RoundTripper) http.RoundTripper {
return authrt.NewRoundTripper(tokenExchangeClient, rt)
return authrt.NewRoundTripper(tokenExchangeClient, rt, group)
}),
TLSClientConfig: tlsConfig,
}
configProviders[i] = NewDirectConfigProvider(config)
configProviders[group] = NewDirectConfigProvider(config)
}
clients := resources.NewClientFactoryForMultipleAPIServers(configProviders)
@@ -13,8 +13,6 @@ import (
"github.com/urfave/cli/v2"
"k8s.io/client-go/tools/cache"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/migrate"
@@ -23,8 +21,6 @@ import (
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
"github.com/grafana/grafana/pkg/services/apiserver/standalone"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/apps/provisioning/pkg/controller"
informer "github.com/grafana/grafana/apps/provisioning/pkg/generated/informers/externalversions"
@@ -54,11 +50,6 @@ func RunJobController(opts standalone.BuildInfo, c *cli.Context, cfg *setting.Cf
cancel()
}()
// Use unified storage client and API clients for testing purposes.
// TODO: remove this once the processing logic is in place
// https://github.com/grafana/git-ui-sync-project/issues/467
go temporaryPeriodicTestClients(ctx, logger, controllerCfg)
// Jobs informer and controller (resync ~60s like in register.go)
jobInformerFactory := informer.NewSharedInformerFactoryWithOptions(
controllerCfg.provisioningClient,
@@ -223,74 +214,3 @@ func setupWorkers(controllerCfg *jobsControllerConfig) ([]jobs.Worker, error) {
return workers, nil
}
// Use unified storage client for testing purposes.
// TODO: remove this once the processing logic is in place
// https://github.com/grafana/git-ui-sync-project/issues/467
func temporaryPeriodicTestClients(ctx context.Context, logger logging.Logger, controllerCfg *jobsControllerConfig) {
tick := time.NewTicker(controllerCfg.resyncInterval)
logger.Info("starting periodic using clients", "interval", controllerCfg.resyncInterval.String())
fetchAndLog := func(ctx context.Context) {
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants us access to all namespaces.
if err != nil {
logger.Error("failed to set identity", "error", err)
return
}
resp, err := controllerCfg.unified.CountManagedObjects(ctx, &resourcepb.CountManagedObjectsRequest{
Kind: string(utils.ManagerKindRepo),
})
if err != nil {
logger.Error("failed to list managed objects", "error", err)
} else {
if len(resp.Items) == 0 {
logger.Info("no managed objects found")
} else {
for _, obj := range resp.Items {
logger.Info("manage object counts", "item", obj)
}
}
}
// List all supported resources
client, err := controllerCfg.clients.Clients(ctx, "")
if err != nil {
logger.Error("failed to get resource clients", "error", err)
return
}
for kind, gvr := range resources.SupportedProvisioningResources {
logger := logger.With("kind", kind, "gvr", gvr.String())
logger.Info("fetching resources")
resourceClient, gvk, err := client.ForResource(ctx, gvr)
if err != nil {
logger.Error("failed to get resource client", "error", err)
continue
}
logger = logger.With("gvk", gvk.String())
list, err := resourceClient.List(ctx, metav1.ListOptions{})
if err != nil {
logger.Error("failed to list resources", "error", err)
continue
}
for _, item := range list.Items {
logger.Info("resource", "name", item.GetName(), "namespace", item.GetNamespace())
}
}
}
fetchAndLog(ctx) // Initial fetch
for {
select {
case <-ctx.Done():
tick.Stop()
return
case <-tick.C:
// Periodic fetch
fetchAndLog(ctx)
}
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ func RunRepoController(opts standalone.BuildInfo, c *cli.Context, cfg *setting.C
controllerCfg.resyncInterval,
)
resourceLister := resources.NewResourceListerForMigrations(controllerCfg.unified, nil, nil)
resourceLister := resources.NewResourceLister(controllerCfg.unified)
jobs, err := jobs.NewJobStore(controllerCfg.provisioningClient.ProvisioningV0alpha1(), 30*time.Second)
if err != nil {
return fmt.Errorf("create API client job store: %w", err)
+12 -15
View File
@@ -24,8 +24,6 @@ type PluginManagementCfg struct {
GrafanaAppURL string
Features Features
HideAngularDeprecation []string
}
// Features contains the feature toggles used for the plugin management system.
@@ -42,20 +40,19 @@ type Features struct {
// NewPluginManagementCfg returns a new PluginManagementCfg.
func NewPluginManagementCfg(devMode bool, pluginsPath string, pluginSettings setting.PluginSettings, pluginsAllowUnsigned []string,
pluginsCDNURLTemplate string, appURL string, features Features,
grafanaComAPIURL string, disablePlugins []string, hideAngularDeprecation []string, forwardHostEnvVars []string, grafanaComAPIToken string,
grafanaComAPIURL string, disablePlugins []string, forwardHostEnvVars []string, grafanaComAPIToken string,
) *PluginManagementCfg {
return &PluginManagementCfg{
PluginsPath: pluginsPath,
DevMode: devMode,
PluginSettings: pluginSettings,
PluginsAllowUnsigned: pluginsAllowUnsigned,
DisablePlugins: disablePlugins,
PluginsCDNURLTemplate: pluginsCDNURLTemplate,
GrafanaComAPIURL: grafanaComAPIURL,
GrafanaAppURL: appURL,
Features: features,
HideAngularDeprecation: hideAngularDeprecation,
ForwardHostEnvVars: forwardHostEnvVars,
GrafanaComAPIToken: grafanaComAPIToken,
PluginsPath: pluginsPath,
DevMode: devMode,
PluginSettings: pluginSettings,
PluginsAllowUnsigned: pluginsAllowUnsigned,
DisablePlugins: disablePlugins,
PluginsCDNURLTemplate: pluginsCDNURLTemplate,
GrafanaComAPIURL: grafanaComAPIURL,
GrafanaAppURL: appURL,
Features: features,
ForwardHostEnvVars: forwardHostEnvVars,
GrafanaComAPIToken: grafanaComAPIToken,
}
}
@@ -3,7 +3,6 @@ package validation
import (
"context"
"errors"
"slices"
"time"
"github.com/grafana/grafana/pkg/plugins"
@@ -117,6 +116,5 @@ func (a *AngularDetector) Validate(ctx context.Context, p *plugins.Plugin) error
}).WithMessage("angular plugins are not supported")
}
}
p.Angular.HideDeprecation = slices.Contains(a.cfg.HideAngularDeprecation, p.ID)
return nil
}
+1 -2
View File
@@ -79,8 +79,7 @@ var (
)
type AngularMeta struct {
Detected bool `json:"detected"`
HideDeprecation bool `json:"hideDeprecation"`
Detected bool `json:"detected"`
}
// JSONData represents the plugin's plugin.json
+12 -13
View File
@@ -6,8 +6,6 @@ import (
"fmt"
"strings"
"github.com/grafana/grafana/apps/iam/pkg/reconcilers"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/prometheus/client_golang/prometheus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -19,7 +17,10 @@ import (
"k8s.io/kube-openapi/pkg/common"
"k8s.io/kube-openapi/pkg/spec3"
authtypes "github.com/grafana/authlib/types"
"github.com/grafana/grafana/apps/iam/pkg/reconcilers"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
authlib "github.com/grafana/authlib/types"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
@@ -45,7 +46,6 @@ var errNoResource = errors.New("resource name is required")
// This is used just so wire has something unique to return
type FolderAPIBuilder struct {
gv schema.GroupVersion
features featuremgmt.FeatureToggles
namespacer request.NamespaceMapper
folderSvc folder.Service
@@ -70,12 +70,12 @@ func RegisterAPIService(cfg *setting.Cfg,
folderPermissionsSvc accesscontrol.FolderPermissionsService,
accessControl accesscontrol.AccessControl,
acService accesscontrol.Service,
accessClient authlib.AccessClient,
registerer prometheus.Registerer,
unified resource.ResourceClient,
zanzanaClient zanzana.Client,
) *FolderAPIBuilder {
builder := &FolderAPIBuilder{
gv: resourceInfo.GroupVersion(),
features: features,
namespacer: request.GetNamespaceMapper(cfg),
folderSvc: folderSvc,
@@ -91,17 +91,15 @@ func RegisterAPIService(cfg *setting.Cfg,
return builder
}
func NewAPIService(ac authtypes.AccessClient) *FolderAPIBuilder {
func NewAPIService(ac authlib.AccessClient) *FolderAPIBuilder {
return &FolderAPIBuilder{
gv: resourceInfo.GroupVersion(),
namespacer: request.GetNamespaceMapper(nil),
authorizer: newMultiTenantAuthorizer(ac),
ignoreLegacy: true,
}
}
func (b *FolderAPIBuilder) GetGroupVersion() schema.GroupVersion {
return b.gv
return resourceInfo.GroupVersion()
}
func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
@@ -115,13 +113,14 @@ func addKnownTypes(scheme *runtime.Scheme, gv schema.GroupVersion) {
}
func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
addKnownTypes(scheme, b.gv)
gv := b.GetGroupVersion()
addKnownTypes(scheme, gv)
// Link this version to the internal representation.
// This is used for server-side-apply (PATCH), and avoids the error:
// "no kind is registered for the type"
addKnownTypes(scheme, schema.GroupVersion{
Group: b.gv.Group,
Group: gv.Group,
Version: runtime.APIVersionInternal,
})
@@ -129,8 +128,8 @@ func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
// if err := playlist.RegisterConversions(scheme); err != nil {
// return err
// }
metav1.AddToGroupVersion(scheme, b.gv)
return scheme.SetVersionPriority(b.gv)
metav1.AddToGroupVersion(scheme, gv)
return scheme.SetVersionPriority(gv)
}
func (b *FolderAPIBuilder) AllowedV0Alpha1Resources() []string {
@@ -118,8 +118,6 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
us := grafanarest.NewMockStorage(t)
b := &FolderAPIBuilder{
gv: resourceInfo.GroupVersion(),
features: nil,
namespacer: func(_ int64) string { return "123" },
folderSvc: foldertest.NewFakeService(),
storage: us,
@@ -194,8 +192,6 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
).Once()
b := &FolderAPIBuilder{
gv: resourceInfo.GroupVersion(),
features: nil,
namespacer: func(_ int64) string { return "123" },
folderSvc: foldertest.NewFakeService(),
storage: us,
@@ -365,8 +361,6 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) {
}
b := &FolderAPIBuilder{
gv: resourceInfo.GroupVersion(),
features: nil,
namespacer: func(_ int64) string { return "123" },
folderSvc: foldertest.NewFakeService(),
storage: us,
@@ -461,8 +455,6 @@ func TestFolderAPIBuilder_Mutate_Create(t *testing.T) {
us := grafanarest.NewMockStorage(t)
sm := resource.NewMockResourceClient(t)
b := &FolderAPIBuilder{
gv: resourceInfo.GroupVersion(),
features: nil,
namespacer: func(_ int64) string { return "123" },
folderSvc: foldertest.NewFakeService(),
storage: us,
@@ -569,8 +561,6 @@ func TestFolderAPIBuilder_Mutate_Update(t *testing.T) {
us := grafanarest.NewMockStorage(t)
sm := resource.NewMockResourceClient(t)
b := &FolderAPIBuilder{
gv: resourceInfo.GroupVersion(),
features: nil,
namespacer: func(_ int64) string { return "123" },
folderSvc: foldertest.NewFakeService(),
storage: us,
@@ -0,0 +1,7 @@
INSERT INTO {{ .Ident .UserTable }}
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
({{ .Arg .Command.UID }}, 0, {{ .Arg .Command.Login }}, {{ .Arg .Command.Email }}, {{ .Arg .Command.Name }},
{{ .Arg .Command.OrgID }}, false, {{ .Arg .Command.IsDisabled }}, false,
false, true, '', '', {{ .Arg .Command.Created }}, {{ .Arg .Command.Updated }}, {{ .Arg .Command.LastSeenAt }})
+115 -1
View File
@@ -9,6 +9,7 @@ import (
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
"github.com/grafana/grafana/pkg/services/sqlstore/session"
"github.com/grafana/grafana/pkg/storage/legacysql"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
)
@@ -107,10 +108,28 @@ type ServiceAccount struct {
UID string
Name string
Disabled bool
Role string
Created time.Time
Updated time.Time
}
type CreateServiceAccountCommand struct {
UID string
Name string
Email string
Login string
Role string
IsDisabled bool
OrgID int64
Created DBTime
Updated DBTime
LastSeenAt time.Time
}
type CreateServiceAccountResult struct {
ServiceAccount ServiceAccount
}
var sqlQueryServiceAccountsTemplate = mustTemplate("service_accounts_query.sql")
func newListServiceAccounts(sql *legacysql.LegacyDatabaseHelper, q *ListServiceAccountsQuery) listServiceAccountsQuery {
@@ -167,7 +186,7 @@ func (s *legacySQLStore) ListServiceAccounts(ctx context.Context, ns claims.Name
var lastID int64
for rows.Next() {
var s ServiceAccount
err := rows.Scan(&s.ID, &s.UID, &s.Name, &s.Disabled, &s.Created, &s.Updated)
err := rows.Scan(&s.ID, &s.UID, &s.Name, &s.Disabled, &s.Role, &s.Created, &s.Updated)
if err != nil {
return res, err
}
@@ -286,3 +305,98 @@ func (s *legacySQLStore) ListServiceAccountTokens(ctx context.Context, ns claims
return res, err
}
var sqlCreateServiceAccountTemplate = mustTemplate("create_service_account.sql")
func newCreateServiceAccount(sql *legacysql.LegacyDatabaseHelper, cmd *CreateServiceAccountCommand) createServiceAccountQuery {
return createServiceAccountQuery{
SQLTemplate: sqltemplate.New(sql.DialectForDriver()),
UserTable: sql.Table("user"),
OrgUserTable: sql.Table("org_user"),
Command: cmd,
}
}
type createServiceAccountQuery struct {
sqltemplate.SQLTemplate
UserTable string
OrgUserTable string
Command *CreateServiceAccountCommand
}
func (r createServiceAccountQuery) Validate() error {
return nil
}
func (s *legacySQLStore) CreateServiceAccount(ctx context.Context, ns claims.NamespaceInfo, cmd CreateServiceAccountCommand) (*CreateServiceAccountResult, error) {
cmd.OrgID = ns.OrgID
cmd.Email = cmd.Login
now := time.Now().UTC().Truncate(time.Second)
lastSeenAt := now.AddDate(-10, 0, 0) // Set last seen 10 years ago like in user service
cmd.Created = NewDBTime(now)
cmd.Updated = NewDBTime(now)
cmd.LastSeenAt = lastSeenAt
if ns.OrgID == 0 {
return nil, fmt.Errorf("expected non zero org id")
}
sql, err := s.sql(ctx)
if err != nil {
return nil, err
}
req := newCreateServiceAccount(sql, &cmd)
var createdSA ServiceAccount
err = sql.DB.GetSqlxSession().WithTransaction(ctx, func(st *session.SessionTx) error {
userQuery, err := sqltemplate.Execute(sqlCreateServiceAccountTemplate, req)
if err != nil {
return fmt.Errorf("execute service account template %q: %w", sqlCreateServiceAccountTemplate.Name(), err)
}
serviceAccountID, err := st.ExecWithReturningId(ctx, userQuery, req.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to create service account: %w", err)
}
orgUserCmd := &CreateOrgUserCommand{
OrgID: ns.OrgID,
UserID: serviceAccountID,
Role: cmd.Role,
Created: cmd.Created,
Updated: cmd.Updated,
}
orgUserReq := newCreateOrgUser(sql, orgUserCmd)
orgUserQuery, err := sqltemplate.Execute(sqlCreateOrgUserTemplate, orgUserReq)
if err != nil {
return fmt.Errorf("execute org_user template %q: %w", sqlCreateOrgUserTemplate.Name(), err)
}
_, err = st.Exec(ctx, orgUserQuery, orgUserReq.GetArgs()...)
if err != nil {
return fmt.Errorf("failed to create org_user relationship: %w", err)
}
createdSA = ServiceAccount{
ID: serviceAccountID,
UID: cmd.UID,
Name: cmd.Name,
Role: cmd.Role,
Disabled: cmd.IsDisabled,
Created: cmd.Created.Time,
Updated: cmd.Updated.Time,
}
return nil
})
if err != nil {
return nil, err
}
return &CreateServiceAccountResult{ServiceAccount: createdSA}, nil
}
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM {{ .Ident .UserTable }} as u JOIN {{ .Ident .OrgUserTable }} as o ON u.id = o.user_id
+49 -3
View File
@@ -2,9 +2,11 @@ package legacy
import (
"context"
"database/sql/driver"
"embed"
"fmt"
"text/template"
"time"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/storage/legacysql"
@@ -22,6 +24,8 @@ type LegacyIdentityStore interface {
GetServiceAccountInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetServiceAccountInternalIDQuery) (*GetServiceAccountInternalIDResult, error)
ListServiceAccounts(ctx context.Context, ns claims.NamespaceInfo, query ListServiceAccountsQuery) (*ListServiceAccountResult, error)
CreateServiceAccount(ctx context.Context, ns claims.NamespaceInfo, cmd CreateServiceAccountCommand) (*CreateServiceAccountResult, error)
ListServiceAccountTokens(ctx context.Context, ns claims.NamespaceInfo, query ListServiceAccountTokenQuery) (*ListServiceAccountTokenResult, error)
GetTeamInternalID(ctx context.Context, ns claims.NamespaceInfo, query GetTeamInternalIDQuery) (*GetTeamInternalIDResult, error)
@@ -30,9 +34,7 @@ type LegacyIdentityStore interface {
ListTeamMembers(ctx context.Context, ns claims.NamespaceInfo, query ListTeamMembersQuery) (*ListTeamMembersResult, error)
}
var (
_ LegacyIdentityStore = (*legacySQLStore)(nil)
)
var _ LegacyIdentityStore = (*legacySQLStore)(nil)
func NewLegacySQLStores(sql legacysql.LegacyDatabaseProvider) LegacyIdentityStore {
return &legacySQLStore{
@@ -58,3 +60,47 @@ func mustTemplate(filename string) *template.Template {
}
panic(fmt.Sprintf("template file not found: %s", filename))
}
type DBTime struct {
time.Time
}
func NewDBTime(t time.Time) DBTime {
return DBTime{Time: t}
}
func (t DBTime) Value() (driver.Value, error) {
if t.IsZero() {
return nil, nil
}
return t.Format(time.DateTime), nil
}
func (t *DBTime) Scan(value interface{}) error {
if value == nil {
t.Time = time.Time{}
return nil
}
var parsedTime time.Time
var err error
switch v := value.(type) {
case []byte:
parsedTime, err = time.Parse(time.DateTime, string(v))
case string:
parsedTime, err = time.Parse(time.DateTime, v)
case time.Time:
parsedTime = v
default:
return fmt.Errorf("could not scan type %T into DBTime", value)
}
if err != nil {
return fmt.Errorf("could not parse time: %w", err)
}
t.Time = parsedTime
return nil
}
+46 -10
View File
@@ -88,6 +88,12 @@ func TestIdentityQueries(t *testing.T) {
return &v
}
createServiceAccounts := func(cmd *CreateServiceAccountCommand) sqltemplate.SQLTemplate {
v := newCreateServiceAccount(nodb, cmd)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
return &v
}
listServiceAccountTokens := func(q *ListServiceAccountTokenQuery) sqltemplate.SQLTemplate {
v := newListServiceAccountTokens(nodb, q)
v.SQLTemplate = mocks.NewTestingSQLTemplate()
@@ -361,8 +367,8 @@ func TestIdentityQueries(t *testing.T) {
OrgID: 1,
UserID: 123,
Role: "Viewer",
Created: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
Updated: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
Created: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
}),
},
{
@@ -371,8 +377,8 @@ func TestIdentityQueries(t *testing.T) {
OrgID: 2,
UserID: 456,
Role: "Admin",
Created: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC),
Updated: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC),
Created: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
}),
},
},
@@ -391,9 +397,9 @@ func TestIdentityQueries(t *testing.T) {
IsProvisioned: false,
Salt: "randomsalt",
Rands: "randomrands",
Created: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
Updated: time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC),
LastSeenAt: time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC),
Created: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
LastSeenAt: NewDBTime(time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC)),
Role: "Viewer",
}),
},
@@ -411,13 +417,43 @@ func TestIdentityQueries(t *testing.T) {
IsProvisioned: true,
Salt: "adminsalt",
Rands: "adminrands",
Created: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC),
Updated: time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC),
LastSeenAt: time.Date(2013, 2, 1, 10, 30, 0, 0, time.UTC),
Created: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
LastSeenAt: NewDBTime(time.Date(2013, 2, 1, 10, 30, 0, 0, time.UTC)),
Role: "Admin",
}),
},
},
sqlCreateServiceAccountTemplate: {
{
Name: "create_service_account_basic",
Data: createServiceAccounts(&CreateServiceAccountCommand{
UID: "abcdef",
Name: "Service Account 1",
Email: "sa-1-service-account-1",
Login: "sa-1-service-account-1",
IsDisabled: false,
OrgID: 1,
Created: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)),
LastSeenAt: time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC),
}),
},
{
Name: "create_service_account_disabled",
Data: createServiceAccounts(&CreateServiceAccountCommand{
UID: "abcdef",
Name: "Disabled Service Account",
Email: "sa-2-disabled-service-account",
Login: "sa-2-disabled-service-account",
IsDisabled: true,
OrgID: 2,
Created: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
Updated: NewDBTime(time.Date(2023, 2, 1, 10, 30, 0, 0, time.UTC)),
LastSeenAt: time.Date(2013, 2, 1, 10, 30, 0, 0, time.UTC),
}),
},
},
},
})
}
@@ -0,0 +1,7 @@
INSERT INTO `grafana`.`user`
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-1-service-account-1', 'sa-1-service-account-1', 'Service Account 1',
1, false, FALSE, false,
false, true, '', '', '2023-01-01 12:00:00 +0000 UTC', '2023-01-01 12:00:00 +0000 UTC', '2013-01-01 12:00:00 +0000 UTC')
@@ -0,0 +1,7 @@
INSERT INTO `grafana`.`user`
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-2-disabled-service-account', 'sa-2-disabled-service-account', 'Disabled Service Account',
2, false, TRUE, false,
false, true, '', '', '2023-02-01 10:30:00 +0000 UTC', '2023-02-01 10:30:00 +0000 UTC', '2013-02-01 10:30:00 +0000 UTC')
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM `grafana`.`user` as u JOIN `grafana`.`org_user` as o ON u.id = o.user_id
@@ -0,0 +1,7 @@
INSERT INTO "grafana"."user"
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-1-service-account-1', 'sa-1-service-account-1', 'Service Account 1',
1, false, FALSE, false,
false, true, '', '', '2023-01-01 12:00:00 +0000 UTC', '2023-01-01 12:00:00 +0000 UTC', '2013-01-01 12:00:00 +0000 UTC')
@@ -0,0 +1,7 @@
INSERT INTO "grafana"."user"
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-2-disabled-service-account', 'sa-2-disabled-service-account', 'Disabled Service Account',
2, false, TRUE, false,
false, true, '', '', '2023-02-01 10:30:00 +0000 UTC', '2023-02-01 10:30:00 +0000 UTC', '2013-02-01 10:30:00 +0000 UTC')
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -0,0 +1,7 @@
INSERT INTO "grafana"."user"
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-1-service-account-1', 'sa-1-service-account-1', 'Service Account 1',
1, false, FALSE, false,
false, true, '', '', '2023-01-01 12:00:00 +0000 UTC', '2023-01-01 12:00:00 +0000 UTC', '2013-01-01 12:00:00 +0000 UTC')
@@ -0,0 +1,7 @@
INSERT INTO "grafana"."user"
(uid, version, login, email, name, org_id, is_admin, is_disabled, email_verified,
is_provisioned, is_service_account, salt, rands, created, updated, last_seen_at)
VALUES
('abcdef', 0, 'sa-2-disabled-service-account', 'sa-2-disabled-service-account', 'Disabled Service Account',
2, false, TRUE, false,
false, true, '', '', '2023-02-01 10:30:00 +0000 UTC', '2023-02-01 10:30:00 +0000 UTC', '2013-02-01 10:30:00 +0000 UTC')
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
@@ -3,6 +3,7 @@ SELECT
u.uid,
u.name,
u.is_disabled,
o.role,
u.created,
u.updated
FROM "grafana"."user" as u JOIN "grafana"."org_user" as o ON u.id = o.user_id
+11 -11
View File
@@ -303,9 +303,9 @@ type CreateUserCommand struct {
IsProvisioned bool
Salt string
Rands string
Created time.Time
Updated time.Time
LastSeenAt time.Time
Created DBTime
Updated DBTime
LastSeenAt DBTime
Role string
}
@@ -317,8 +317,8 @@ type CreateOrgUserCommand struct {
OrgID int64
UserID int64
Role string
Created time.Time
Updated time.Time
Created DBTime
Updated DBTime
}
type DeleteUserCommand struct {
@@ -395,9 +395,9 @@ func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo
cmd.Salt = salt
cmd.Rands = rands
cmd.Created = now
cmd.Updated = now
cmd.LastSeenAt = lastSeenAt
cmd.Created = NewDBTime(now)
cmd.Updated = NewDBTime(now)
cmd.LastSeenAt = NewDBTime(lastSeenAt)
cmd.Role = "Viewer" // TODO: https://github.com/grafana/identity-access-team/issues/1552
sql, err := s.sql(ctx)
@@ -451,9 +451,9 @@ func (s *legacySQLStore) CreateUser(ctx context.Context, ns claims.NamespaceInfo
IsProvisioned: cmd.IsProvisioned,
Salt: cmd.Salt,
Rands: cmd.Rands,
Created: cmd.Created,
Updated: cmd.Updated,
LastSeenAt: cmd.LastSeenAt,
Created: cmd.Created.Time,
Updated: cmd.Updated.Time,
LastSeenAt: cmd.LastSeenAt.Time,
IsServiceAccount: false,
}
+35 -26
View File
@@ -138,6 +138,7 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
teamBindingResource := iamv0.TeamBindingResourceInfo
storage[teamBindingResource.StoragePath()] = team.NewLegacyBindingStore(b.store)
// User store registration
userResource := iamv0.UserResourceInfo
legacyStore := user.NewLegacyStore(b.store, b.legacyAccessClient, b.enableAuthnMutation)
storage[userResource.StoragePath()] = legacyStore
@@ -157,8 +158,26 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
}
storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.store)
// Service Accounts store registration
serviceAccountResource := iamv0.ServiceAccountResourceInfo
storage[serviceAccountResource.StoragePath()] = serviceaccount.NewLegacyStore(b.store, b.legacyAccessClient)
saLegacyStore := serviceaccount.NewLegacyStore(b.store, b.legacyAccessClient, b.enableAuthnMutation)
storage[serviceAccountResource.StoragePath()] = saLegacyStore
if b.enableDualWriter {
store, err := grafanaregistry.NewRegistryStore(opts.Scheme, serviceAccountResource, opts.OptsGetter)
if err != nil {
return err
}
dw, err := opts.DualWriteBuilder(serviceAccountResource.GroupResource(), saLegacyStore, store)
if err != nil {
return err
}
storage[serviceAccountResource.StoragePath()] = dw
}
storage[serviceAccountResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store)
if b.sso != nil {
@@ -269,15 +288,21 @@ func (b *IdentityAccessManagementAPIBuilder) GetAuthorizer() authorizer.Authoriz
func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
switch a.GetOperation() {
case admission.Create:
if a.GetKind() == iamv0.UserResourceInfo.GroupVersionKind() {
switch typedObj := a.GetObject().(type) {
case *iamv0.User:
return b.validateCreateUser(ctx, a, o)
case *iamv0.ServiceAccount:
return serviceaccount.ValidateOnCreate(ctx, typedObj)
}
return nil
case admission.Connect:
case admission.Delete:
case admission.Update:
return nil
case admission.Delete:
return nil
case admission.Connect:
return nil
}
return nil
}
@@ -289,7 +314,7 @@ func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(ctx context.Cont
requester, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewBadRequest("no identity found")
return apierrors.NewUnauthorized("no identity found")
}
// Temporary validation that the user is not trying to create a Grafana Admin without being a Grafana Admin.
@@ -312,8 +337,11 @@ func (b *IdentityAccessManagementAPIBuilder) validateCreateUser(ctx context.Cont
func (b *IdentityAccessManagementAPIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
switch a.GetOperation() {
case admission.Create:
if a.GetKind() == iamv0.UserResourceInfo.GroupVersionKind() {
return b.mutateUser(ctx, a, o)
switch typedObj := a.GetObject().(type) {
case *iamv0.User:
return user.MutateOnCreate(ctx, typedObj)
case *iamv0.ServiceAccount:
return serviceaccount.MutateOnCreate(ctx, typedObj)
}
return nil
case admission.Update:
@@ -327,25 +355,6 @@ func (b *IdentityAccessManagementAPIBuilder) Mutate(ctx context.Context, a admis
return nil
}
func (b *IdentityAccessManagementAPIBuilder) mutateUser(_ context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
userObj, ok := a.GetObject().(*iamv0.User)
if !ok {
return nil
}
userObj.Spec.Email = strings.ToLower(userObj.Spec.Email)
userObj.Spec.Login = strings.ToLower(userObj.Spec.Login)
if userObj.Spec.Login == "" {
userObj.Spec.Login = userObj.Spec.Email
}
if userObj.Spec.Email == "" {
userObj.Spec.Email = userObj.Spec.Login
}
return nil
}
func NewLocalStore(resourceInfo utils.ResourceInfo, scheme *runtime.Scheme, defaultOptsGetter generic.RESTOptionsGetter,
reg prometheus.Registerer, ac types.AccessClient, storageBackend resource.StorageBackend) (grafanarest.Storage, error) {
server, err := resource.NewResourceServer(resource.ResourceServerOptions{
@@ -0,0 +1,16 @@
package serviceaccount
import (
"context"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
func MutateOnCreate(ctx context.Context, obj *iamv0alpha1.ServiceAccount) error {
// External service accounts have None org role by default
if obj.Spec.Plugin != "" && obj.Spec.Role == "" {
obj.Spec.Role = iamv0alpha1.ServiceAccountOrgRoleNone
}
return nil
}
@@ -0,0 +1,69 @@
package serviceaccount
import (
"context"
"testing"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/stretchr/testify/require"
"k8s.io/apiserver/pkg/endpoints/request"
)
func TestMutateOnCreate(t *testing.T) {
ctx := request.WithNamespace(context.Background(), "default")
testCases := []struct {
name string
inputSA *iamv0alpha1.ServiceAccount
expectedRole iamv0alpha1.ServiceAccountOrgRole
}{
{
name: "non-external sa with editor role",
inputSA: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "My Test SA",
Role: iamv0alpha1.ServiceAccountOrgRoleEditor,
},
},
expectedRole: iamv0alpha1.ServiceAccountOrgRoleEditor,
},
{
name: "external sa with admin role is not overridden",
inputSA: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "grafana-plugin-name",
Plugin: "grafana-plugin-name",
Role: iamv0alpha1.ServiceAccountOrgRoleAdmin,
},
},
expectedRole: iamv0alpha1.ServiceAccountOrgRoleAdmin,
},
{
name: "external sa with no role specified gets none",
inputSA: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "sa-1-extsvc-grafana-plugin-name",
Plugin: "grafana-plugin-name",
},
},
expectedRole: iamv0alpha1.ServiceAccountOrgRoleNone,
},
{
name: "non-external sa with no role specified",
inputSA: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Another SA",
},
},
expectedRole: "", // Role is not mutated if not present and not external
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := MutateOnCreate(ctx, tc.inputSA)
require.NoError(t, err)
require.Equal(t, tc.expectedRole, tc.inputSA.Spec.Role)
})
}
}
+89 -7
View File
@@ -3,7 +3,9 @@ package serviceaccount
import (
"context"
"fmt"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -12,9 +14,12 @@ import (
claims "github.com/grafana/authlib/types"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/infra/slugify"
"github.com/grafana/grafana/pkg/registry/apis/iam/common"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/util"
)
var (
@@ -23,17 +28,85 @@ var (
_ rest.Getter = (*LegacyStore)(nil)
_ rest.Lister = (*LegacyStore)(nil)
_ rest.Storage = (*LegacyStore)(nil)
_ rest.CreaterUpdater = (*LegacyStore)(nil)
_ rest.GracefulDeleter = (*LegacyStore)(nil)
_ rest.CollectionDeleter = (*LegacyStore)(nil)
)
var resource = iamv0alpha1.ServiceAccountResourceInfo
func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient) *LegacyStore {
return &LegacyStore{store, ac}
func NewLegacyStore(store legacy.LegacyIdentityStore, ac claims.AccessClient, enableAuthnMutation bool) *LegacyStore {
return &LegacyStore{store, ac, enableAuthnMutation}
}
type LegacyStore struct {
store legacy.LegacyIdentityStore
ac claims.AccessClient
store legacy.LegacyIdentityStore
ac claims.AccessClient
enableAuthnMutation bool
}
// DeleteCollection implements rest.CollectionDeleter.
func (s *LegacyStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete")
}
// Delete implements rest.GracefulDeleter.
func (s *LegacyStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "delete")
}
// Update implements rest.Updater.
func (s *LegacyStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
return nil, false, apierrors.NewMethodNotSupported(resource.GroupResource(), "update")
}
// Create implements rest.Creater.
func (s *LegacyStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if !s.enableAuthnMutation {
return nil, apierrors.NewMethodNotSupported(resource.GroupResource(), "create")
}
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
saObj, ok := obj.(*iamv0alpha1.ServiceAccount)
if !ok {
return nil, fmt.Errorf("expected ServiceAccount object, got %T", obj)
}
if saObj.GenerateName != "" {
saObj.Name = saObj.GenerateName + util.GenerateShortUID()
saObj.GenerateName = ""
}
if createValidation != nil {
if err := createValidation(ctx, obj); err != nil {
return nil, err
}
}
login := serviceaccounts.GenerateLogin(serviceaccounts.ServiceAccountPrefix, ns.OrgID, saObj.Spec.Title)
if saObj.Spec.Plugin != "" {
login = serviceaccounts.ExtSvcLoginPrefix(ns.OrgID) + slugify.Slugify(saObj.Spec.Title)
}
createCmd := legacy.CreateServiceAccountCommand{
IsDisabled: saObj.Spec.Disabled,
Name: saObj.Spec.Title,
UID: saObj.Name,
Login: strings.ToLower(login),
Role: string(saObj.Spec.Role),
}
result, err := s.store.CreateServiceAccount(ctx, ns, createCmd)
if err != nil {
return nil, err
}
iamSA := s.toSAItem(result.ServiceAccount, ns.Value)
return &iamSA, nil
}
func (s *LegacyStore) New() runtime.Object {
@@ -72,7 +145,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt
items := make([]iamv0alpha1.ServiceAccount, 0, len(found.Items))
for _, sa := range found.Items {
items = append(items, toSAItem(sa, ns.Value))
items = append(items, s.toSAItem(sa, ns.Value))
}
return &common.ListResponse[iamv0alpha1.ServiceAccount]{
@@ -93,7 +166,7 @@ func (s *LegacyStore) List(ctx context.Context, options *internalversion.ListOpt
return obj, nil
}
func toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount {
func (s *LegacyStore) toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount {
item := iamv0alpha1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: sa.UID,
@@ -102,8 +175,10 @@ func toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount {
CreationTimestamp: metav1.NewTime(sa.Created),
},
Spec: iamv0alpha1.ServiceAccountSpec{
Plugin: extractPluginNameFromTitle(sa.Name),
Title: sa.Name,
Disabled: sa.Disabled,
Role: iamv0alpha1.ServiceAccountOrgRole(sa.Role),
},
}
obj, _ := utils.MetaAccessor(&item)
@@ -112,6 +187,13 @@ func toSAItem(sa legacy.ServiceAccount, ns string) iamv0alpha1.ServiceAccount {
return item
}
func extractPluginNameFromTitle(title string) string {
if strings.HasPrefix(title, serviceaccounts.ExtSvcPrefix) {
return strings.TrimLeft(title, serviceaccounts.ExtSvcPrefix)
}
return ""
}
func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
ns, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
@@ -130,6 +212,6 @@ func (s *LegacyStore) Get(ctx context.Context, name string, options *metav1.GetO
return nil, resource.NewNotFound(name)
}
res := toSAItem(found.Items[0], ns.Value)
res := s.toSAItem(found.Items[0], ns.Value)
return &res, nil
}
@@ -0,0 +1,58 @@
package serviceaccount
import (
"context"
"fmt"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"github.com/grafana/authlib/types"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
)
func ValidateOnCreate(ctx context.Context, obj *iamv0alpha1.ServiceAccount) error {
if obj.Spec.Title == "" {
return apierrors.NewBadRequest("service account must have a title")
}
requester, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized("no identity found")
}
requestedRole := identity.RoleType(obj.Spec.Role)
if !requestedRole.IsValid() {
return apierrors.NewBadRequest(fmt.Sprintf("invalid role: %s", requestedRole))
}
if obj.Spec.Plugin != "" {
if !strings.HasPrefix(obj.Spec.Title, serviceaccounts.ExtSvcPrefix) {
return apierrors.NewBadRequest("title of external service accounts must start with " + serviceaccounts.ExtSvcPrefix)
}
if !strings.HasSuffix(obj.Spec.Title, strings.ToLower(obj.Spec.Plugin)) {
return apierrors.NewBadRequest("title of external service accounts must end with " + strings.ToLower(obj.Spec.Plugin))
}
if !requester.IsIdentityType(types.TypeAccessPolicy) {
return apierrors.NewForbidden(iamv0alpha1.ServiceAccountResourceInfo.GroupResource(),
obj.Name,
fmt.Errorf("only service identities can create external service accounts"))
}
if obj.Spec.Role != iamv0alpha1.ServiceAccountOrgRoleNone {
return apierrors.NewBadRequest("external service accounts must have role None")
}
}
if !requester.HasRole(requestedRole) {
return apierrors.NewForbidden(iamv0alpha1.ServiceAccountResourceInfo.GroupResource(),
obj.Name,
fmt.Errorf("can not assign a role higher than user's role"))
}
return nil
}
@@ -0,0 +1,195 @@
package serviceaccount
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/grafana/authlib/types"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
)
func TestValidateOnCreate(t *testing.T) {
tests := []struct {
name string
serviceAccount *iamv0alpha1.ServiceAccount
requester *identity.StaticRequester
expectError bool
errorContains string
}{
{
name: "valid service account with user requester",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Test Service Account",
Role: iamv0alpha1.ServiceAccountOrgRoleViewer,
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
expectError: false,
},
{
name: "empty title",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "",
Role: iamv0alpha1.ServiceAccountOrgRoleViewer,
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "service account must have a title",
},
{
name: "invalid role",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Test Service Account",
Role: "InvalidRole",
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "invalid role",
},
{
name: "role higher than requester's role",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Test Service Account",
Role: iamv0alpha1.ServiceAccountOrgRoleAdmin,
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleViewer,
},
expectError: true,
errorContains: "can not assign a role higher than user's role",
},
{
name: "external service account - valid",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: serviceaccounts.ExtSvcPrefix + "test-plugin",
Role: iamv0alpha1.ServiceAccountOrgRoleNone,
Plugin: "test-plugin",
},
},
requester: &identity.StaticRequester{
Type: types.TypeAccessPolicy,
OrgRole: identity.RoleAdmin,
},
expectError: false,
},
{
name: "external service account - invalid title prefix",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "invalid-prefix-test",
Role: iamv0alpha1.ServiceAccountOrgRoleNone,
Plugin: "test",
},
},
requester: &identity.StaticRequester{
Type: types.TypeAccessPolicy,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "title of external service accounts must start with " + serviceaccounts.ExtSvcPrefix,
},
{
name: "external service account - invalid title suffix",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: serviceaccounts.ExtSvcPrefix + "wrong-suffix",
Role: iamv0alpha1.ServiceAccountOrgRoleNone,
Plugin: "test",
},
},
requester: &identity.StaticRequester{
Type: types.TypeAccessPolicy,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "title of external service accounts must end with test",
},
{
name: "external service account - non-access-policy requester",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: serviceaccounts.ExtSvcPrefix + "test-test",
Role: iamv0alpha1.ServiceAccountOrgRoleNone,
Plugin: "test",
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "only service identities can create external service accounts",
},
{
name: "external service account - role not None",
serviceAccount: &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: serviceaccounts.ExtSvcPrefix + "test-test",
Role: iamv0alpha1.ServiceAccountOrgRoleViewer,
Plugin: "test",
},
},
requester: &identity.StaticRequester{
Type: types.TypeAccessPolicy,
OrgRole: identity.RoleAdmin,
},
expectError: true,
errorContains: "external service accounts must have role None",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := identity.WithRequester(
context.Background(),
tt.requester,
)
err := ValidateOnCreate(ctx, tt.serviceAccount)
if tt.expectError {
require.Error(t, err)
if tt.errorContains != "" {
require.Contains(t, err.Error(), tt.errorContains)
}
} else {
require.NoError(t, err)
}
})
}
}
func TestValidateOnCreate_NoRequester(t *testing.T) {
serviceAccount := &iamv0alpha1.ServiceAccount{
Spec: iamv0alpha1.ServiceAccountSpec{
Title: "Test Service Account",
Role: iamv0alpha1.ServiceAccountOrgRoleViewer,
},
}
err := ValidateOnCreate(context.Background(), serviceAccount)
require.Error(t, err)
require.Contains(t, err.Error(), "no identity found")
}
+22
View File
@@ -0,0 +1,22 @@
package user
import (
"context"
"strings"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
)
func MutateOnCreate(ctx context.Context, obj *iamv0alpha1.User) error {
obj.Spec.Email = strings.ToLower(obj.Spec.Email)
obj.Spec.Login = strings.ToLower(obj.Spec.Login)
if obj.Spec.Login == "" {
obj.Spec.Login = obj.Spec.Email
}
if obj.Spec.Email == "" {
obj.Spec.Email = obj.Spec.Login
}
return nil
}
+78
View File
@@ -0,0 +1,78 @@
package user
import (
"context"
"testing"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/stretchr/testify/require"
)
func TestMutateOnCreate_LoginEmail(t *testing.T) {
testCases := []struct {
name string
inputUser *iamv0alpha1.User
expectedLogin string
expectedEmail string
}{
{
name: "login and email provided with mixed case",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "Test.User",
Email: "Test.User@example.com",
},
},
expectedLogin: "test.user",
expectedEmail: "test.user@example.com",
},
{
name: "only email provided",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Email: "Only.Email@example.com",
},
},
expectedLogin: "only.email@example.com",
expectedEmail: "only.email@example.com",
},
{
name: "only login provided",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "Only.Login",
},
},
expectedLogin: "only.login",
expectedEmail: "only.login",
},
{
name: "login and email already lowercase",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Login: "already.lower",
Email: "already.lower@example.com",
},
},
expectedLogin: "already.lower",
expectedEmail: "already.lower@example.com",
},
{
name: "both login and email are empty",
inputUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{},
},
expectedLogin: "",
expectedEmail: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := MutateOnCreate(context.Background(), tc.inputUser)
require.NoError(t, err)
require.Equal(t, tc.expectedLogin, tc.inputUser.Spec.Login)
require.Equal(t, tc.expectedEmail, tc.inputUser.Spec.Email)
})
}
}
@@ -215,7 +215,7 @@ func (rc *RepositoryController) handleDelete(ctx context.Context, obj *provision
} else {
err := rc.finalizer.process(ctx, repo, obj.Finalizers)
if err != nil {
logger.Warn("error running finalizer", "err")
logger.Warn("error running finalizer", "err", err)
}
}
@@ -120,26 +120,39 @@ func NewClientFactory(configProvider apiserver.RestConfigProvider) ClientFactory
}
// NewClientFactoryForMultipleAPIServers creates a ClientFactory for multiple API servers
func NewClientFactoryForMultipleAPIServers(configProviders []apiserver.RestConfigProvider) ClientFactory {
clientFactories := make([]ClientFactory, len(configProviders))
func NewClientFactoryForMultipleAPIServers(configProviders map[string]apiserver.RestConfigProvider) ClientFactory {
clientFactories := make(map[string]ClientFactory)
for i, configProvider := range configProviders {
for api, configProvider := range configProviders {
clientFactory := NewClientFactory(configProvider)
clientFactories[i] = clientFactory
clientFactories[api] = clientFactory
}
return &multiClientFactory{clientFactories: clientFactories}
}
type multiClientFactory struct {
clientFactories []ClientFactory
clientFactories map[string]ClientFactory
}
func (m *multiClientFactory) Clients(ctx context.Context, namespace string) (ResourceClients, error) {
for _, clientFactory := range m.clientFactories {
return clientFactory.Clients(ctx, namespace)
clients := make(map[string]ResourceClients)
for group, clientFactory := range m.clientFactories {
c, err := clientFactory.Clients(ctx, namespace)
if err != nil {
return nil, err
}
clients[group] = c
}
return nil, fmt.Errorf("no client factories available")
if len(clients) == 0 {
return nil, fmt.Errorf("no client factories available")
}
return &multiResourceClients{
namespace: namespace,
resourceClientsByAPIGroup: clients,
}, nil
}
func (f *clientFactory) Clients(ctx context.Context, namespace string) (ResourceClients, error) {
@@ -283,6 +296,52 @@ func (c *resourceClients) User(ctx context.Context) (dynamic.ResourceInterface,
return v, err
}
type multiResourceClients struct {
namespace string
mutex sync.Mutex
resourceClientsByAPIGroup map[string]ResourceClients
}
// ForKind returns a client for a kind.
// If the kind has a version, it will be used.
// If the kind does not have a version, the preferred version will be used.
func (c *multiResourceClients) ForKind(ctx context.Context, gvk schema.GroupVersionKind) (dynamic.ResourceInterface, schema.GroupVersionResource, error) {
c.mutex.Lock()
defer c.mutex.Unlock()
resourceClients, ok := c.resourceClientsByAPIGroup[gvk.Group]
if !ok {
return nil, schema.GroupVersionResource{}, fmt.Errorf("no clients provider for group %s", gvk.Group)
}
return resourceClients.ForKind(ctx, gvk)
}
// ForResource returns a client for a resource.
// If the resource has a version, it will be used.
// If the resource does not have a version, the preferred version will be used.
func (c *multiResourceClients) ForResource(ctx context.Context, gvr schema.GroupVersionResource) (dynamic.ResourceInterface, schema.GroupVersionKind, error) {
c.mutex.Lock()
defer c.mutex.Unlock()
resourceClients, ok := c.resourceClientsByAPIGroup[gvr.Group]
if !ok {
return nil, schema.GroupVersionKind{}, fmt.Errorf("no clients provider for group %s", gvr.Group)
}
return resourceClients.ForResource(ctx, gvr)
}
func (c *multiResourceClients) Folder(ctx context.Context) (dynamic.ResourceInterface, error) {
client, _, err := c.ForResource(ctx, FolderResource)
return client, err
}
func (c *multiResourceClients) User(ctx context.Context) (dynamic.ResourceInterface, error) {
v, _, err := c.ForResource(ctx, UserResource)
return v, err
}
// ForEach applies the function to each resource returned from the list operation
func ForEach(ctx context.Context, client dynamic.ResourceInterface, fn func(item *unstructured.Unstructured) error) error {
var continueToken string
+2 -2
View File
@@ -801,7 +801,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
if err != nil {
return nil, err
}
folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, registerer, resourceClient, zanzanaClient)
folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient)
storageBackendImpl := noopstorage.ProvideStorageBackend()
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, registerer, storageBackendImpl, storageBackendImpl)
if err != nil {
@@ -1389,7 +1389,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
if err != nil {
return nil, err
}
folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, registerer, resourceClient, zanzanaClient)
folderAPIBuilder := folders.RegisterAPIService(cfg, featureToggles, apiserverService, folderimplService, folderPermissionsService, accessControl, acimplService, accessClient, registerer, resourceClient, zanzanaClient)
storageBackendImpl := noopstorage.ProvideStorageBackend()
identityAccessManagementAPIBuilder, err := iam.RegisterAPIService(featureToggles, apiserverService, ssosettingsimplService, sqlStore, accessControl, accessClient, registerer, storageBackendImpl, storageBackendImpl)
if err != nil {
+3 -3
View File
@@ -45,7 +45,7 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features
return NewZanzanaClient(
fmt.Sprintf("stacks-%s", cfg.StackID),
ZanzanaClientConfig{
Address: cfg.ZanzanaClient.Addr,
URL: cfg.ZanzanaClient.Addr,
Token: cfg.ZanzanaClient.Token,
TokenExchangeURL: cfg.ZanzanaClient.TokenExchangeURL,
ServerCertFile: cfg.ZanzanaClient.ServerCertFile,
@@ -94,7 +94,7 @@ func ProvideZanzana(cfg *setting.Cfg, db db.DB, tracer tracing.Tracer, features
}
type ZanzanaClientConfig struct {
Address string
URL string
Token string
TokenExchangeURL string
ServerCertFile string
@@ -128,7 +128,7 @@ func NewZanzanaClient(namespace string, cfg ZanzanaClientConfig) (zanzana.Client
),
}
conn, err := grpc.NewClient(cfg.Address, dialOptions...)
conn, err := grpc.NewClient(cfg.URL, dialOptions...)
if err != nil {
return nil, fmt.Errorf("failed to create zanzana client to remote server: %w", err)
}
@@ -300,14 +300,6 @@ func (ss *SqlStore) AddDataSource(ctx context.Context, cmd *datasources.AddDataS
return err
}
}
sess.PublishAfterCommit(&events.DataSourceCreated{
Timestamp: time.Now(),
Name: cmd.Name,
ID: ds.ID,
UID: cmd.UID,
OrgID: cmd.OrgID,
})
return nil
})
}
@@ -109,33 +109,6 @@ func TestIntegrationDataAccess(t *testing.T) {
_, err := ss.AddDataSource(context.Background(), &cmd)
require.ErrorContains(t, err, "invalid format of UID")
})
t.Run("fires an event when the datasource is added", func(t *testing.T) {
db := db.InitTestDB(t)
sqlStore := SqlStore{db: db}
var created *events.DataSourceCreated
db.Bus().AddEventListener(func(ctx context.Context, e *events.DataSourceCreated) error {
created = e
return nil
})
_, err := sqlStore.AddDataSource(context.Background(), &defaultAddDatasourceCommand)
require.NoError(t, err)
require.Eventually(t, func() bool {
return assert.NotNil(t, created)
}, time.Second, time.Millisecond)
query := datasources.GetDataSourcesQuery{OrgID: 10}
dataSources, err := sqlStore.GetDataSources(context.Background(), &query)
require.NoError(t, err)
require.Equal(t, 1, len(dataSources))
require.Equal(t, dataSources[0].ID, created.ID)
require.Equal(t, dataSources[0].UID, created.UID)
require.Equal(t, int64(10), created.OrgID)
require.Equal(t, "nisse", created.Name)
})
})
t.Run("UpdateDataSource", func(t *testing.T) {
@@ -261,14 +261,14 @@ resource "grafana_contact_point" "contact_point_2b661702215368fe" {
title = "test-title"
message = "test-message"
tlsConfig {
tls_config {
insecure_skip_verify = false
ca_certificate = "[REDACTED]"
client_certificate = "[REDACTED]"
client_key = "[REDACTED]"
}
hmacConfig {
hmac_config {
secret = "[REDACTED]"
header = "X-Grafana-Alerting-Signature"
timestamp_header = "X-Grafana-Alerting-Timestamp"
@@ -240,14 +240,14 @@ resource "grafana_contact_point" "contact_point_2b661702215368fe" {
title = "test-title"
message = "test-message"
tlsConfig {
tls_config {
insecure_skip_verify = false
ca_certificate = "-----BEGIN CERTIFICATE-----\nMIGrMF+gAwIBAgIBATAFBgMrZXAwADAeFw0yNDExMTYxMDI4MzNaFw0yNTExMTYx\nMDI4MzNaMAAwKjAFBgMrZXADIQCf30GvRnHbs9gukA3DLXDK6W5JVgYw6mERU/60\n2M8+rjAFBgMrZXADQQCGmeaRp/AcjeqmJrF5Yh4d7aqsMSqVZvfGNDc0ppXyUgS3\nWMQ1+3T+/pkhU612HR0vFd3vyFhmB4yqFoNV8RML\n-----END CERTIFICATE-----"
client_certificate = "-----BEGIN CERTIFICATE-----\nMIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw\nDgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow\nEjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d\n7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B\n5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr\nBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1\nNDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l\nWf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc\n6MF9+Yw1Yy0t\n-----END CERTIFICATE-----"
client_key = "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49\nAwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q\nEKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==\n-----END EC PRIVATE KEY-----"
}
hmacConfig {
hmac_config {
secret = "test-hmac-secret"
header = "X-Grafana-Alerting-Signature"
timestamp_header = "X-Grafana-Alerting-Timestamp"
@@ -325,8 +325,8 @@ type WebhookIntegration struct {
ExtraHeaders *map[string]string `json:"headers,omitempty" yaml:"headers,omitempty" hcl:"headers"`
Title *string `json:"title,omitempty" yaml:"title,omitempty" hcl:"title"`
Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"`
TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tlsConfig,block"`
HMACConfig *HMACConfig `json:"hmacConfig,omitempty" yaml:"hmacConfig,omitempty" hcl:"hmacConfig,block"`
TLSConfig *TLSConfig `json:"tlsConfig,omitempty" yaml:"tlsConfig,omitempty" hcl:"tls_config,block"`
HMACConfig *HMACConfig `json:"hmacConfig,omitempty" yaml:"hmacConfig,omitempty" hcl:"hmac_config,block"`
HTTPConfig *HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty" hcl:"http_config,block"`
Payload *CustomPayload `json:"payload,omitempty" yaml:"payload,omitempty" hcl:"payload,block"`
-24
View File
@@ -8,7 +8,6 @@ import (
"strings"
"time"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -96,11 +95,6 @@ func (ss *sqlStore) Insert(ctx context.Context, orga *org.Org) (int64, error) {
return err
}
}
sess.PublishAfterCommit(&events.OrgCreated{
Timestamp: orga.Created,
Id: orga.ID,
Name: orga.Name,
})
return nil
})
if err != nil {
@@ -156,12 +150,6 @@ func (ss *sqlStore) Update(ctx context.Context, cmd *org.UpdateOrgCommand) error
return org.ErrOrgNotFound.Errorf("failed to update organization with ID: %d", cmd.OrgId)
}
sess.PublishAfterCommit(&events.OrgUpdated{
Timestamp: orga.Updated,
Id: orga.ID,
Name: orga.Name,
})
return nil
})
}
@@ -200,12 +188,6 @@ func (ss *sqlStore) UpdateAddress(ctx context.Context, cmd *org.UpdateOrgAddress
return err
}
sess.PublishAfterCommit(&events.OrgUpdated{
Timestamp: org.Updated,
Id: org.ID,
Name: org.Name,
})
return nil
})
}
@@ -345,12 +327,6 @@ func (ss *sqlStore) CreateWithMember(ctx context.Context, cmd *org.CreateOrgComm
_, err := sess.Insert(&user)
sess.PublishAfterCommit(&events.OrgCreated{
Timestamp: orga.Created,
Id: orga.ID,
Name: orga.Name,
})
return err
}); err != nil {
return &orga, err
@@ -1219,38 +1219,6 @@ func TestLoader_Load_Angular(t *testing.T) {
}
}
func TestLoader_HideAngularDeprecation(t *testing.T) {
fakePluginSource := &fakes.FakePluginSource{
PluginClassFunc: func(ctx context.Context) plugins.Class {
return plugins.ClassExternal
},
DiscoverFunc: sources.NewLocalSource(plugins.ClassExternal, []string{filepath.Join(testDataDir(t), "valid-v2-signature")}).Discover,
}
for _, tc := range []struct {
name string
cfg *config.PluginManagementCfg
}{
{name: "with plugin id in HideAngularDeprecation list", cfg: &config.PluginManagementCfg{
HideAngularDeprecation: []string{"one-app", "two-panel", "test-datasource", "three-datasource"},
}},
{name: "without plugin id in HideAngularDeprecation list", cfg: &config.PluginManagementCfg{
HideAngularDeprecation: []string{"one-app", "two-panel", "three-datasource"},
}},
{name: "with empty HideAngularDeprecation", cfg: &config.PluginManagementCfg{
HideAngularDeprecation: nil,
}},
} {
t.Run(tc.name, func(t *testing.T) {
l := newLoaderWithOpts(t, tc.cfg, loaderDepOpts{
angularInspector: angularinspector.AlwaysAngularFakeInspector,
})
p, err := l.Load(context.Background(), fakePluginSource)
require.NoError(t, err)
require.Empty(t, p, "plugin shouldn't have been loaded")
})
}
}
func TestLoader_Load_NestedPlugins(t *testing.T) {
parent := &plugins.Plugin{
JSONData: plugins.JSONData{
@@ -37,7 +37,6 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro
},
cfg.GrafanaComAPIURL,
cfg.DisablePlugins,
cfg.HideAngularDeprecation,
cfg.ForwardHostEnvVars,
cfg.GrafanaComSSOAPIToken,
), nil
+3 -16
View File
@@ -43,22 +43,9 @@ func ProvideServiceAccountsStore(cfg *setting.Cfg, store db.DB, apiKeyService ap
}
}
// generateLogin makes a generated string to have a ID for the service account across orgs and it's name
// this causes you to create a service account with the same name in different orgs
// not the same name in the same org
// -- WARNING:
// -- if you change this function you need to change the ExtSvcLoginPrefix as well
// -- to make sure they are not considered as regular service accounts
func generateLogin(prefix string, orgId int64, name string) string {
generatedLogin := fmt.Sprintf("%v-%v-%v", prefix, orgId, strings.ToLower(name))
// in case the name has multiple spaces or dashes in the prefix or otherwise, replace them with a single dash
generatedLogin = strings.Replace(generatedLogin, "--", "-", 1)
return strings.ReplaceAll(generatedLogin, " ", "-")
}
// CreateServiceAccount creates service account
func (s *ServiceAccountsStoreImpl) CreateServiceAccount(ctx context.Context, orgId int64, saForm *serviceaccounts.CreateServiceAccountForm) (*serviceaccounts.ServiceAccountDTO, error) {
login := generateLogin(serviceaccounts.ServiceAccountPrefix, orgId, saForm.Name)
login := serviceaccounts.GenerateLogin(serviceaccounts.ServiceAccountPrefix, orgId, saForm.Name)
isDisabled := false
role := org.RoleViewer
if saForm.IsDisabled != nil {
@@ -483,7 +470,7 @@ func (s *ServiceAccountsStoreImpl) MigrateApiKeysToServiceAccounts(ctx context.C
func (s *ServiceAccountsStoreImpl) CreateServiceAccountFromApikey(ctx context.Context, key *apikey.APIKey) error {
prefix := "sa-autogen"
cmd := user.CreateUserCommand{
Login: generateLogin(prefix, key.OrgID, key.Name),
Login: serviceaccounts.GenerateLogin(prefix, key.OrgID, key.Name),
Name: fmt.Sprintf("%v-%v", prefix, key.Name),
OrgID: key.OrgID,
DefaultOrgRole: string(key.Role),
@@ -501,7 +488,7 @@ func (s *ServiceAccountsStoreImpl) CreateServiceAccountFromApikey(ctx context.Co
// a unique service account by adding suffixes to the initial login name (e.g. -001, -002, ... , -010).
for i := 1; errCreateSA != nil && i <= attempts; i++ {
serviceAccountName := fmt.Sprintf("%s-%03d", key.Name, i)
cmd.Login = generateLogin(prefix, key.OrgID, serviceAccountName)
cmd.Login = serviceaccounts.GenerateLogin(prefix, key.OrgID, serviceAccountName)
newSA, errCreateSA = s.userService.CreateServiceAccount(tctx, &cmd)
if errCreateSA != nil && !errors.Is(errCreateSA, serviceaccounts.ErrServiceAccountAlreadyExists) {
break
-15
View File
@@ -1,8 +1,6 @@
package serviceaccounts
import (
"fmt"
"strings"
"time"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
@@ -217,16 +215,3 @@ var AccessEvaluator = accesscontrol.EvalAny(
accesscontrol.EvalPermission(ActionRead),
accesscontrol.EvalPermission(ActionCreate),
)
func ExtSvcLoginPrefix(orgID int64) string {
return fmt.Sprintf("%s%d-%s", ServiceAccountPrefix, orgID, ExtSvcPrefix)
}
func IsExternalServiceAccount(login string) bool {
parts := strings.SplitAfter(login, "-")
if len(parts) < 4 {
return false
}
return parts[0] == ServiceAccountPrefix && parts[2] == ExtSvcPrefix
}
+32
View File
@@ -0,0 +1,32 @@
package serviceaccounts
import (
"fmt"
"strings"
)
// generateLogin makes a generated string to have a ID for the service account across orgs and it's name
// this causes you to create a service account with the same name in different orgs
// not the same name in the same org
// -- WARNING:
// -- if you change this function you need to change the ExtSvcLoginPrefix as well
// -- to make sure they are not considered as regular service accounts
func GenerateLogin(prefix string, orgId int64, name string) string {
generatedLogin := fmt.Sprintf("%v-%v-%v", prefix, orgId, strings.ToLower(name))
// in case the name has multiple spaces or dashes in the prefix or otherwise, replace them with a single dash
generatedLogin = strings.Replace(generatedLogin, "--", "-", 1)
return strings.ReplaceAll(generatedLogin, " ", "-")
}
func ExtSvcLoginPrefix(orgID int64) string {
return fmt.Sprintf("%s%d-%s", ServiceAccountPrefix, orgID, ExtSvcPrefix)
}
func IsExternalServiceAccount(login string) bool {
parts := strings.SplitAfter(login, "-")
if len(parts) < 4 {
return false
}
return parts[0] == ServiceAccountPrefix && parts[2] == ExtSvcPrefix
}
-4
View File
@@ -31,10 +31,6 @@ type DBSession struct {
type DBTransactionFunc func(sess *DBSession) error
func (sess *DBSession) publishAfterCommit(msg any) {
sess.events = append(sess.events, msg)
}
func (sess *DBSession) PublishAfterCommit(msg any) {
sess.events = append(sess.events, msg)
}
-15
View File
@@ -7,7 +7,6 @@ import (
"strings"
"time"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util"
@@ -99,14 +98,6 @@ func (ss *SQLStore) createUser(ctx context.Context, sess *DBSession, args user.C
return usr, err
}
sess.publishAfterCommit(&events.UserCreated{
Timestamp: usr.Created,
Id: usr.ID,
Name: usr.Name,
Login: usr.Login,
Email: usr.Email,
})
orgUser := org.OrgUser{
OrgID: orgID,
UserID: usr.ID,
@@ -183,11 +174,5 @@ func (ss *SQLStore) getOrCreateOrg(sess *DBSession, orgName string) (int64, erro
}
}
sess.publishAfterCommit(&events.OrgCreated{
Timestamp: org.Created,
Id: org.ID,
Name: org.Name,
})
return org.ID, nil
}
-16
View File
@@ -7,7 +7,6 @@ import (
"strings"
"time"
"github.com/grafana/grafana/pkg/events"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
@@ -63,13 +62,6 @@ func (ss *sqlStore) Insert(ctx context.Context, cmd *user.User) (int64, error) {
if _, err = sess.Insert(cmd); err != nil {
return err
}
sess.PublishAfterCommit(&events.UserCreated{
Timestamp: cmd.Created,
Id: cmd.ID,
Name: cmd.Name,
Login: cmd.Login,
Email: cmd.Email,
})
return nil
})
@@ -294,14 +286,6 @@ func (ss *sqlStore) Update(ctx context.Context, cmd *user.UpdateUserCommand) err
}
}
sess.PublishAfterCommit(&events.UserUpdated{
Timestamp: usr.Created,
Id: usr.ID,
Name: usr.Name,
Login: usr.Login,
Email: usr.Email,
})
return nil
})
}
-1
View File
@@ -205,7 +205,6 @@ type Cfg struct {
PluginForcePublicKeyDownload bool
PluginSkipPublicKeyDownload bool
DisablePlugins []string
HideAngularDeprecation []string
ForwardHostEnvVars []string
PreinstallPluginsAsync []InstallPlugin
PreinstallPluginsSync []InstallPlugin
-1
View File
@@ -141,7 +141,6 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error {
cfg.PluginsAllowUnsigned = util.SplitString(pluginsSection.Key("allow_loading_unsigned_plugins").MustString(""))
cfg.DisablePlugins = util.SplitString(pluginsSection.Key("disable_plugins").MustString(""))
cfg.HideAngularDeprecation = util.SplitString(pluginsSection.Key("hide_angular_deprecation").MustString(""))
cfg.ForwardHostEnvVars = util.SplitString(pluginsSection.Key("forward_host_env_vars").MustString(""))
disablePreinstall := pluginsSection.Key("preinstall_disabled").MustBool(false)
if !disablePreinstall {
-4
View File
@@ -85,14 +85,10 @@ func Test_readPluginSettings(t *testing.T) {
_, err = sec.NewKey("plugin_catalog_hidden_plugins", tc.f("plugin3"))
require.NoError(t, err)
_, err = sec.NewKey("hide_angular_deprecation", tc.f("a", "b", "c"))
require.NoError(t, err)
err = cfg.readPluginSettings(cfg.Raw)
require.NoError(t, err)
require.Equal(t, []string{"plugin1", "plugin2"}, cfg.DisablePlugins)
require.Equal(t, []string{"plugin3", "plugin1", "plugin2"}, cfg.PluginCatalogHiddenPlugins)
require.Equal(t, []string{"a", "b", "c"}, cfg.HideAngularDeprecation)
})
}
})
+2 -2
View File
@@ -40,8 +40,8 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/sql"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
"github.com/grafana/grafana/pkg/tests"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util/testutil"
)
type StorageType string
@@ -135,7 +135,7 @@ func testSetup(t testing.TB, opts ...setupOption) (context.Context, storage.Inte
_, err = server.IsHealthy(ctx, &resourcepb.HealthCheckRequest{})
require.NoError(t, err)
case StorageTypeUnified:
tests.SkipIntegrationTestInShortMode(t)
testutil.SkipIntegrationTestInShortMode(t)
dbstore := infraDB.InitTestDB(t)
cfg := setting.NewCfg()
@@ -17,7 +17,7 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/sql"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
test "github.com/grafana/grafana/pkg/storage/unified/testing"
"github.com/grafana/grafana/pkg/tests"
"github.com/grafana/grafana/pkg/util/testutil"
)
func newTestBackend(b testing.TB) resource.StorageBackend {
@@ -39,10 +39,7 @@ func newTestBackend(b testing.TB) resource.StorageBackend {
}
func TestIntegrationBenchmarkSQLStorageBackend(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
tests.SkipIntegrationTestInShortMode(t)
testutil.SkipIntegrationTestInShortMode(t)
opts := test.DefaultBenchmarkOptions()
if db.IsTestDbSQLite() {
opts.Concurrency = 1 // to avoid SQLite database is locked error
@@ -53,10 +50,7 @@ func TestIntegrationBenchmarkSQLStorageBackend(t *testing.T) {
func TestIntegrationBenchmarkResourceServer(t *testing.T) {
t.Skip("skipping slow test, causing CI to fail due to timeout")
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
tests.SkipIntegrationTestInShortMode(t)
testutil.SkipIntegrationTestInShortMode(t)
ctx := context.Background()
opts := &test.BenchmarkOptions{
@@ -26,7 +26,6 @@ import (
"github.com/grafana/grafana/pkg/storage/unified/sql"
"github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl"
unitest "github.com/grafana/grafana/pkg/storage/unified/testing"
"github.com/grafana/grafana/pkg/tests"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util/testutil"
)
@@ -102,10 +101,7 @@ func TestIntegrationSQLStorageBackend(t *testing.T) {
}
func TestIntegrationSearchAndStorage(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
tests.SkipIntegrationTestInShortMode(t)
testutil.SkipIntegrationTestInShortMode(t)
ctx := context.Background()
+3 -3
View File
@@ -26,10 +26,10 @@ import (
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestMain(m *testing.M) {
@@ -1172,7 +1172,7 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) {
// Reproduces a bug where folder deletion does not check for attached library panels.
func TestIntegrationFolderDeletionBlockedByLibraryElements(t *testing.T) {
tests.SkipIntegrationTestInShortMode(t)
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
@@ -1251,7 +1251,7 @@ func TestIntegrationFolderDeletionBlockedByLibraryElements(t *testing.T) {
}
func TestIntegrationRootFolderDeletionBlockedByLibraryElementsInSubfolder(t *testing.T) {
tests.SkipIntegrationTestInShortMode(t)
testutil.SkipIntegrationTestInShortMode(t)
if !db.IsTestDbSQLite() {
t.Skip("test only on sqlite for now")
@@ -0,0 +1,274 @@
package identity
import (
"context"
"fmt"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
)
var gvrServiceAccounts = schema.GroupVersionResource{
Group: "iam.grafana.app",
Version: "v0alpha1",
Resource: "serviceaccounts",
}
func TestIntegrationServiceAccounts(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
// TODO: Figure out why rest.Mode4 is failing
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3}
for _, mode := range modes {
t.Run(fmt.Sprintf("Service Account CRUD operations with dual writer mode %d", mode), func(t *testing.T) {
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: false,
DisableAnonymous: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
"serviceaccounts.iam.grafana.app": {
DualWriterMode: mode,
},
},
EnableFeatureToggles: []string{
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
featuremgmt.FlagKubernetesAuthnMutation,
},
})
doServiceAccountCRUDTestsUsingTheNewAPIs(t, helper)
if mode < 3 {
doServiceAccountCRUDTestsUsingTheLegacyAPIs(t, helper)
}
})
}
}
func doServiceAccountCRUDTestsUsingTheNewAPIs(t *testing.T, helper *apis.K8sTestHelper) {
t.Run("should create service account and get it using the new APIs as a GrafanaAdmin", func(t *testing.T) {
ctx := context.Background()
saClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrServiceAccounts,
})
created, err := saClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-create-v0.yaml"), metav1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, created)
createdSpec := created.Object["spec"].(map[string]interface{})
require.Equal(t, "Test Service Account 1", createdSpec["title"])
require.Equal(t, false, createdSpec["disabled"])
require.Empty(t, createdSpec["plugin"])
createdUID := created.GetName()
require.NotEmpty(t, createdUID)
_, err = saClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
fetched, err := saClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, fetched)
fetchedSpec := fetched.Object["spec"].(map[string]interface{})
require.Equal(t, "Test Service Account 1", fetchedSpec["title"])
require.Equal(t, false, fetchedSpec["disabled"])
require.Empty(t, fetchedSpec["plugin"])
require.Equal(t, createdUID, fetched.GetName())
require.Equal(t, "default", fetched.GetNamespace())
})
t.Run("should not be able to create service account when using a user with insufficient permissions", func(t *testing.T) {
for _, user := range []apis.User{
helper.Org1.Editor,
helper.Org1.Viewer,
} {
t.Run(fmt.Sprintf("with basic role_%s", user.Identity.GetOrgRole()), func(t *testing.T) {
ctx := context.Background()
saClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: user,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrServiceAccounts,
})
_, err := saClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-create-v0.yaml"), metav1.CreateOptions{})
require.Error(t, err)
var statusErr *errors.StatusError
require.ErrorAs(t, err, &statusErr)
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
})
}
})
t.Run("should not be able to create service account with invalid role", func(t *testing.T) {
ctx := context.Background()
saClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrServiceAccounts,
})
saToCreate := helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-invalid-role-v0.yaml")
_, err := saClient.Resource.Create(ctx, saToCreate, metav1.CreateOptions{})
require.Error(t, err)
var statusErr *errors.StatusError
require.ErrorAs(t, err, &statusErr)
require.Equal(t, int32(400), statusErr.ErrStatus.Code)
require.Contains(t, statusErr.ErrStatus.Message, "invalid role: InvalidRole")
})
t.Run("should not be able to create service account with higher role than the user", func(t *testing.T) {
ctx := context.Background()
editorWithSACreate := helper.CreateUser("custom-editor", apis.Org1, org.RoleEditor,
[]resourcepermissions.SetResourcePermissionCommand{
{Actions: []string{serviceaccounts.ActionCreate}},
})
saClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: editorWithSACreate,
Namespace: helper.Namespacer(editorWithSACreate.Identity.GetOrgID()),
GVR: gvrServiceAccounts,
})
saToCreate := helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-higher-role-v0.yaml")
_, err := saClient.Resource.Create(ctx, saToCreate, metav1.CreateOptions{})
require.Error(t, err)
var statusErr *errors.StatusError
require.ErrorAs(t, err, &statusErr)
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
require.Contains(t, statusErr.ErrStatus.Message, "can not assign a role higher than user's role")
})
t.Run("should not be able to create service account without a title", func(t *testing.T) {
ctx := context.Background()
saClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrServiceAccounts,
})
saToCreate := helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-no-title-v0.yaml")
_, err := saClient.Resource.Create(ctx, saToCreate, metav1.CreateOptions{})
require.Error(t, err)
var statusErr *errors.StatusError
require.ErrorAs(t, err, &statusErr)
require.Equal(t, int32(400), statusErr.ErrStatus.Code)
require.Contains(t, statusErr.ErrStatus.Message, "service account must have a title")
})
t.Run("should not be able to create external service account as a user", func(t *testing.T) {
ctx := context.Background()
saClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrServiceAccounts,
})
saToCreate := helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-external-v0.yaml")
_, err := saClient.Resource.Create(ctx, saToCreate, metav1.CreateOptions{})
require.Error(t, err)
var statusErr *errors.StatusError
require.ErrorAs(t, err, &statusErr)
require.Equal(t, int32(403), statusErr.ErrStatus.Code)
require.Contains(t, statusErr.ErrStatus.Message, "only service identities can create external service accounts")
})
t.Run("should create service account with generateName and get it using the new APIs as a GrafanaAdmin", func(t *testing.T) {
ctx := context.Background()
saClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID()),
GVR: gvrServiceAccounts,
})
created, err := saClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/serviceaccount-test-generate-name-v0.yaml"), metav1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, created)
createdSpec := created.Object["spec"].(map[string]interface{})
require.Equal(t, "Test Service Account with GenerateName", createdSpec["title"])
require.Equal(t, false, createdSpec["disabled"])
require.Empty(t, createdSpec["plugin"])
createdUID := created.GetName()
require.NotEmpty(t, createdUID)
require.Contains(t, createdUID, "sa-")
_, err = saClient.Resource.List(ctx, metav1.ListOptions{})
require.NoError(t, err)
fetched, err := saClient.Resource.Get(ctx, createdUID, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, fetched)
fetchedSpec := fetched.Object["spec"].(map[string]interface{})
require.Equal(t, "Test Service Account with GenerateName", fetchedSpec["title"])
require.Equal(t, false, fetchedSpec["disabled"])
require.Empty(t, fetchedSpec["plugin"])
require.Equal(t, createdUID, fetched.GetName())
require.Equal(t, "default", fetched.GetNamespace())
})
}
func doServiceAccountCRUDTestsUsingTheLegacyAPIs(t *testing.T, helper *apis.K8sTestHelper) {
t.Run("should create service account using legacy APIs and get it using the new APIs", func(t *testing.T) {
ctx := context.Background()
saClient := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
GVR: gvrServiceAccounts,
})
legacySAPayload := `{
"name": "Test Service Account 2",
"role": "Viewer"
}`
rsp := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: "POST",
Path: "/api/serviceaccounts",
Body: []byte(legacySAPayload),
}, &serviceaccounts.ServiceAccountDTO{})
require.NotNil(t, rsp)
require.Equal(t, 201, rsp.Response.StatusCode)
require.NotEmpty(t, rsp.Result.UID)
sa, err := saClient.Resource.Get(ctx, rsp.Result.UID, metav1.GetOptions{})
require.NoError(t, err)
require.NotNil(t, sa)
saSpec := sa.Object["spec"].(map[string]interface{})
require.Equal(t, "Test Service Account 2", saSpec["title"])
require.Equal(t, false, saSpec["disabled"])
require.Empty(t, saSpec["plugin"])
require.Equal(t, rsp.Result.UID, sa.GetName())
require.Equal(t, "default", sa.GetNamespace())
})
}
@@ -0,0 +1,9 @@
apiVersion: iam.grafana.app/v0alpha1
kind: ServiceAccount
metadata:
name: test-sa-1
spec:
title: "Test Service Account 1"
disabled: false
role: Editor

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