diff --git a/pkg/services/grafana-apiserver/auth/authorizer/org/org_id.go b/pkg/services/grafana-apiserver/auth/authorizer/org/org_id.go index fc0fc887fa5..9dee8bf81e4 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/org/org_id.go +++ b/pkg/services/grafana-apiserver/auth/authorizer/org/org_id.go @@ -48,7 +48,7 @@ func (auth OrgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attribut // Quick check that the same org is used if signedInUser.OrgID == info.OrgID { - return authorizer.DecisionAllow, "", nil + return authorizer.DecisionNoOpinion, "", nil } // Check if the user has access to the specified org @@ -60,7 +60,7 @@ func (auth OrgIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attribut for _, org := range result { if org.OrgID == info.OrgID { - return authorizer.DecisionAllow, "", nil + return authorizer.DecisionNoOpinion, "", nil } } diff --git a/pkg/services/grafana-apiserver/auth/authorizer/org/org_role.go b/pkg/services/grafana-apiserver/auth/authorizer/org/org_role.go index b2571c4909a..93268f976cc 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/org/org_role.go +++ b/pkg/services/grafana-apiserver/auth/authorizer/org/org_role.go @@ -28,25 +28,25 @@ func (auth OrgRoleAuthorizer) Authorize(ctx context.Context, a authorizer.Attrib switch signedInUser.OrgRole { case org.RoleAdmin: - return authorizer.DecisionAllow, "", nil + return authorizer.DecisionNoOpinion, "", nil case org.RoleEditor: switch a.GetVerb() { case "get", "list", "watch", "create", "update", "patch", "delete", "put", "post": - return authorizer.DecisionAllow, "", nil + return authorizer.DecisionNoOpinion, "", nil default: return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(string(signedInUser.OrgRole), a), nil } case org.RoleViewer: switch a.GetVerb() { case "get", "list", "watch": - return authorizer.DecisionAllow, "", nil + return authorizer.DecisionNoOpinion, "", nil default: return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(string(signedInUser.OrgRole), a), nil } case org.RoleNone: return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(string(signedInUser.OrgRole), a), nil } - return authorizer.DecisionNoOpinion, "", nil + return authorizer.DecisionDeny, "", nil } func errorMessageForGrafanaOrgRole(grafanaOrgRole string, a authorizer.Attributes) string { diff --git a/pkg/services/grafana-apiserver/auth/authorizer/provider.go b/pkg/services/grafana-apiserver/auth/authorizer/provider.go index 306b7e4e285..4441da7dbe0 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/provider.go +++ b/pkg/services/grafana-apiserver/auth/authorizer/provider.go @@ -28,7 +28,11 @@ func ProvideAuthorizer( authorizers = append(authorizers, orgIDAuthorizer) } - authorizers = append(authorizers, orgRoleAuthorizer) + authorizers = append(authorizers, + orgRoleAuthorizer, + // Add this last so that if nothing says authorizer.DecisionDeny, it will pass + authorizerfactory.NewAlwaysAllowAuthorizer(), + ) return union.New(authorizers...) } diff --git a/pkg/services/grafana-apiserver/auth/authorizer/stack/stack_id.go b/pkg/services/grafana-apiserver/auth/authorizer/stack/stack_id.go index 86b3acb008d..88cb83883d7 100644 --- a/pkg/services/grafana-apiserver/auth/authorizer/stack/stack_id.go +++ b/pkg/services/grafana-apiserver/auth/authorizer/stack/stack_id.go @@ -52,5 +52,5 @@ func (auth StackIDAuthorizer) Authorize(ctx context.Context, a authorizer.Attrib return authorizer.DecisionDeny, "user must be in org 1", nil } - return authorizer.DecisionAllow, "", nil + return authorizer.DecisionNoOpinion, "", nil } diff --git a/pkg/tests/apis/README.md b/pkg/tests/apis/README.md new file mode 100644 index 00000000000..4f2b4dca826 --- /dev/null +++ b/pkg/tests/apis/README.md @@ -0,0 +1,3 @@ +# K8s integration tests + +This directory contains integration tests the k8s api services diff --git a/pkg/tests/apis/helper.go b/pkg/tests/apis/helper.go new file mode 100644 index 00000000000..86d0b5666cf --- /dev/null +++ b/pkg/tests/apis/helper.go @@ -0,0 +1,293 @@ +package apis + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" + + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/server" + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/grafana-apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" + "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" + "github.com/grafana/grafana/pkg/services/team/teamimpl" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/services/user/userimpl" + "github.com/grafana/grafana/pkg/tests/testinfra" +) + +type K8sTestHelper struct { + t *testing.T + env server.TestEnv + namespacer request.NamespaceMapper + + Org1 OrgUsers + Org2 OrgUsers + + // // Registered groups + groups []metav1.APIGroup + + // Used to build the URL paths + selectedGVR schema.GroupVersionResource +} + +func NewK8sTestHelper(t *testing.T) *K8sTestHelper { + t.Helper() + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + AppModeProduction: true, // do not start extra port 6443 + DisableAnonymous: true, + EnableFeatureToggles: []string{ + featuremgmt.FlagGrafanaAPIServer, + featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, + }, + }) + + _, env := testinfra.StartGrafanaEnv(t, dir, path) + c := &K8sTestHelper{ + env: *env, + t: t, + namespacer: request.GetNamespaceMapper(nil), + } + + c.Org1 = c.createTestUsers(int64(1)) + c.Org2 = c.createTestUsers(int64(2)) + + // Read the API groups + rsp := doRequest(c, RequestParams{ + User: c.Org1.Viewer, + Path: "/apis", + // Accept: "application/json;g=apidiscovery.k8s.io;v=v2beta1;as=APIGroupDiscoveryList,application/json", + }, &metav1.APIGroupList{}) + c.groups = rsp.Result.Groups + return c +} + +type OrgUsers struct { + Admin User + Editor User + Viewer User +} + +type User struct { + Identity identity.Requester + password string +} + +type RequestParams struct { + User User + Method string // GET, POST, PATCH, etc + Path string + Body []byte + ContentType string + Accept string +} + +type K8sResponse[T any] struct { + Response *http.Response + Body []byte + Result *T + Status *metav1.Status +} + +type AnyResourceResponse = K8sResponse[AnyResource] +type AnyResourceListResponse = K8sResponse[AnyResourceList] + +// This will set the expected Group/Version/Resource and return the discovery info if found +func (c *K8sTestHelper) SetGroupVersionResource(gvr schema.GroupVersionResource) { + c.t.Helper() + + c.selectedGVR = gvr +} + +func (c *K8sTestHelper) PostResource(user User, resource string, payload AnyResource) AnyResourceResponse { + c.t.Helper() + + namespace := payload.Namespace + if namespace == "" { + namespace = c.namespacer(user.Identity.GetOrgID()) + } + + path := fmt.Sprintf("/apis/%s/namespaces/%s/%s", + payload.APIVersion, namespace, resource) + if payload.Name != "" { + path = fmt.Sprintf("%s/%s", path, payload.Name) + } + + body, err := json.Marshal(payload) + require.NoError(c.t, err) + + return doRequest(c, RequestParams{ + Method: http.MethodPost, + Path: path, + User: user, + Body: body, + }, &AnyResource{}) +} + +func (c *K8sTestHelper) PutResource(user User, resource string, payload AnyResource) AnyResourceResponse { + c.t.Helper() + + path := fmt.Sprintf("/apis/%s/namespaces/%s/%s/%s", + payload.APIVersion, payload.Namespace, resource, payload.Name) + + body, err := json.Marshal(payload) + require.NoError(c.t, err) + + return doRequest(c, RequestParams{ + Method: http.MethodPut, + Path: path, + User: user, + Body: body, + }, &AnyResource{}) +} + +func (c *K8sTestHelper) List(user User, namespace string) AnyResourceListResponse { + c.t.Helper() + + return doRequest(c, RequestParams{ + User: user, + Path: fmt.Sprintf("/apis/%s/%s/namespaces/%s/%s", + c.selectedGVR.Group, + c.selectedGVR.Version, + namespace, + c.selectedGVR.Resource), + }, &AnyResourceList{}) +} + +func doRequest[T any](c *K8sTestHelper, params RequestParams, result *T) K8sResponse[T] { + c.t.Helper() + + if params.Method == "" { + params.Method = http.MethodGet + } + + // Get the URL + addr := c.env.Server.HTTPServer.Listener.Addr() + baseUrl := fmt.Sprintf("http://%s", addr) + login := params.User.Identity.GetLogin() + if login != "" && params.User.password != "" { + baseUrl = fmt.Sprintf("http://%s:%s@%s", login, params.User.password, addr) + } + + contentType := params.ContentType + var body io.Reader + if params.Body != nil { + body = bytes.NewReader(params.Body) + if contentType == "" && json.Valid(params.Body) { + contentType = "application/json" + } + } + + req, err := http.NewRequest(params.Method, fmt.Sprintf( + "%s%s", + baseUrl, + params.Path, + ), body) + require.NoError(c.t, err) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if params.Accept != "" { + req.Header.Set("Accept", params.Accept) + } + rsp, err := http.DefaultClient.Do(req) + require.NoError(c.t, err) + + r := K8sResponse[T]{ + Response: rsp, + Result: result, + } + defer func() { + _ = rsp.Body.Close() // ignore any close errors + }() + r.Body, _ = io.ReadAll(rsp.Body) + if json.Valid(r.Body) { + _ = json.Unmarshal(r.Body, r.Result) + + s := &metav1.Status{} + err := json.Unmarshal(r.Body, s) + if err == nil && s.Kind == "Status" { // Usually an error! + r.Status = s + r.Result = nil + } + } else { + _ = yaml.Unmarshal(r.Body, r.Result) + } + return r +} + +func (c K8sTestHelper) createTestUsers(orgId int64) OrgUsers { + c.t.Helper() + + store := c.env.SQLStore + store.Cfg.AutoAssignOrg = true + store.Cfg.AutoAssignOrgId = int(orgId) + quotaService := quotaimpl.ProvideService(store, store.Cfg) + + orgService, err := orgimpl.ProvideService(store, store.Cfg, quotaService) + require.NoError(c.t, err) + + gotID, err := orgService.GetOrCreate(context.Background(), fmt.Sprintf("Org%d", orgId)) + require.NoError(c.t, err) + require.Equal(c.t, orgId, gotID) + + teamSvc := teamimpl.ProvideService(store, store.Cfg) + cache := localcache.ProvideService() + userSvc, err := userimpl.ProvideService(store, + orgService, store.Cfg, teamSvc, cache, quotaService, + supportbundlestest.NewFakeBundleService()) + require.NoError(c.t, err) + + createUser := func(key string, role org.RoleType) User { + u, err := userSvc.Create(context.Background(), &user.CreateUserCommand{ + DefaultOrgRole: string(role), + Password: key, + Login: fmt.Sprintf("%s%d", key, orgId), + OrgID: orgId, + }) + require.NoError(c.t, err) + require.Equal(c.t, orgId, u.OrgID) + require.True(c.t, u.ID > 0) + + s, err := userSvc.GetSignedInUser(context.Background(), &user.GetSignedInUserQuery{ + UserID: u.ID, + Login: u.Login, + Email: u.Email, + OrgID: orgId, + }) + require.NoError(c.t, err) + require.Equal(c.t, orgId, s.OrgID) + require.Equal(c.t, role, s.OrgRole) // make sure the role was set properly + return User{ + Identity: s, + password: key, + } + } + return OrgUsers{ + Admin: createUser("admin", org.RoleAdmin), + Editor: createUser("editor", org.RoleEditor), + Viewer: createUser("viewer", org.RoleViewer), + } +} + +func (c K8sTestHelper) CreateDS(cmd *datasources.AddDataSourceCommand) *datasources.DataSource { + c.t.Helper() + + dataSource, err := c.env.Server.HTTPServer.DataSourcesService.AddDataSource(context.Background(), cmd) + require.NoError(c.t, err) + return dataSource +} diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go new file mode 100644 index 00000000000..053f01a634b --- /dev/null +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -0,0 +1,45 @@ +package playlist + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/grafana/grafana/pkg/tests/apis" +) + +func TestPlaylist(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + helper := apis.NewK8sTestHelper(t) + helper.SetGroupVersionResource( + schema.GroupVersionResource{ + Group: "playlist.grafana.app", + Version: "v0alpha1", + Resource: "playlists", + }) + + t.Run("Check List from different org users", func(t *testing.T) { + // Check view permissions + rsp := helper.List(helper.Org1.Viewer, "default") + require.Equal(t, 200, rsp.Response.StatusCode) + require.NotNil(t, rsp.Result) + require.Empty(t, rsp.Result.Items) + require.Nil(t, rsp.Status) + + // Check view permissions + rsp = helper.List(helper.Org2.Viewer, "default") + require.Equal(t, 403, rsp.Response.StatusCode) // Org2 can not see default namespace + require.Nil(t, rsp.Result) + require.Equal(t, metav1.StatusReasonForbidden, rsp.Status.Reason) + + // Check view permissions + rsp = helper.List(helper.Org2.Viewer, "org-22") + require.Equal(t, 403, rsp.Response.StatusCode) // Unknown/not a member + require.Nil(t, rsp.Result) + require.Equal(t, metav1.StatusReasonForbidden, rsp.Status.Reason) + }) +} diff --git a/pkg/tests/apis/types.go b/pkg/tests/apis/types.go new file mode 100644 index 00000000000..b405de90cd7 --- /dev/null +++ b/pkg/tests/apis/types.go @@ -0,0 +1,45 @@ +package apis + +import ( + "encoding/json" + "os" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" +) + +type AnyResource struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Generic object + Spec map[string]any `json:"spec,omitempty"` +} + +type AnyResourceList struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ListMeta `json:"metadata,omitempty"` + + Items []map[string]any `json:"items,omitempty"` +} + +// Read local JSON or YAML file into a resource +func (c *K8sTestHelper) LoadAnyResource(fpath string) AnyResource { + c.t.Helper() + + //nolint:gosec + raw, err := os.ReadFile(fpath) + require.NoError(c.t, err) + require.NotEmpty(c.t, raw) + + res := &AnyResource{} + if json.Valid(raw) { + err = json.Unmarshal(raw, res) + } else { + err = yaml.Unmarshal(raw, res) + } + require.NoError(c.t, err) + return *res +}