mirror of
https://github.com/grafana/grafana.git
synced 2026-03-13 15:29:48 +08:00
IAM" Add accesscontrol in TeamSearch (#119107)
* add access to team * add access to team * fmt * yarn gen api * gen * yarn generate
This commit is contained in:
@@ -68,6 +68,7 @@ v0alpha1: {
|
||||
email: string
|
||||
provisioned: bool
|
||||
externalUID: string
|
||||
accessControl?: {[string]: bool}
|
||||
}
|
||||
offset: int64
|
||||
totalHits: int64
|
||||
|
||||
@@ -4,11 +4,12 @@ package v0alpha1
|
||||
|
||||
// +k8s:openapi-gen=true
|
||||
type GetSearchTeamsTeamHit struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Email string `json:"email"`
|
||||
Provisioned bool `json:"provisioned"`
|
||||
ExternalUID string `json:"externalUID"`
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Email string `json:"email"`
|
||||
Provisioned bool `json:"provisioned"`
|
||||
ExternalUID string `json:"externalUID"`
|
||||
AccessControl map[string]bool `json:"accessControl,omitempty"`
|
||||
}
|
||||
|
||||
// NewGetSearchTeamsTeamHit creates a new GetSearchTeamsTeamHit object.
|
||||
|
||||
15
apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go
generated
15
apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go
generated
@@ -377,6 +377,21 @@ func schema_pkg_apis_iam_v0alpha1_GetSearchTeamsTeamHit(ref common.ReferenceCall
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
"accessControl": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Allows: true,
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Default: false,
|
||||
Type: []string{"boolean"},
|
||||
Format: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Required: []string{"name", "title", "email", "provisioned", "externalUID"},
|
||||
},
|
||||
|
||||
12
apps/iam/pkg/apis/iam_manifest.go
generated
12
apps/iam/pkg/apis/iam_manifest.go
generated
@@ -539,6 +539,18 @@ var appManifestData = app.ManifestData{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
Properties: map[string]spec.Schema{
|
||||
"accessControl": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"object"},
|
||||
AdditionalProperties: &spec.SchemaOrBool{
|
||||
Schema: &spec.Schema{
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"email": {
|
||||
SchemaProps: spec.SchemaProps{
|
||||
Type: []string{"string"},
|
||||
|
||||
@@ -178,6 +178,7 @@ const injectedRtkApi = api
|
||||
limit: queryArg.limit,
|
||||
offset: queryArg.offset,
|
||||
page: queryArg.page,
|
||||
accesscontrol: queryArg.accesscontrol,
|
||||
},
|
||||
}),
|
||||
providesTags: ['Search'],
|
||||
@@ -951,6 +952,8 @@ export type GetSearchTeamsApiArg = {
|
||||
offset?: number;
|
||||
/** page number to start from */
|
||||
page?: number;
|
||||
/** when true, includes access control metadata in the response */
|
||||
accesscontrol?: boolean;
|
||||
};
|
||||
export type GetSearchUsersApiResponse = unknown;
|
||||
export type GetSearchUsersApiArg = {
|
||||
|
||||
@@ -914,6 +914,14 @@
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accesscontrol",
|
||||
"in": "query",
|
||||
"description": "when true, includes access control metadata in the response",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -4820,6 +4828,13 @@
|
||||
"type": "object",
|
||||
"required": ["name", "title", "email", "provisioned", "externalUID"],
|
||||
"properties": {
|
||||
"accessControl": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
|
||||
@@ -134,7 +134,7 @@ func RegisterAPIService(
|
||||
unified: unified,
|
||||
userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(),
|
||||
unified, user.NewUserLegacySearchClient(orgService, tracing, cfg), features),
|
||||
teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService, tracing), unified, features),
|
||||
teamSearch: NewTeamSearchHandler(tracing, dual, team.NewLegacyTeamSearchClient(teamService, tracing), unified, features, accessClient),
|
||||
tracing: tracing,
|
||||
}
|
||||
builder.userSearchHandler = user.NewSearchHandler(tracing, builder.userSearchClient, features, cfg, accessClient)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/grafana/authlib/authz"
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
common "k8s.io/kube-openapi/pkg/common"
|
||||
"k8s.io/kube-openapi/pkg/spec3"
|
||||
@@ -14,6 +17,7 @@ import (
|
||||
|
||||
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/builder"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -25,21 +29,43 @@ import (
|
||||
"github.com/grafana/grafana/pkg/util/errhttp"
|
||||
)
|
||||
|
||||
type TeamSearchHandler struct {
|
||||
log log.Logger
|
||||
client resourcepb.ResourceIndexClient
|
||||
tracer trace.Tracer
|
||||
features featuremgmt.FeatureToggles
|
||||
// accessControlCheck maps a legacy RBAC action name to a K8s-style check.
|
||||
// The RBAC authz server translates Group/Resource/Verb through the mapper
|
||||
// to resolve the underlying RBAC action.
|
||||
type teamAccessControlCheck struct {
|
||||
action string // legacy RBAC action name returned to callers
|
||||
group string
|
||||
resource string
|
||||
verb string
|
||||
name string // team UID of the resource being checked
|
||||
}
|
||||
|
||||
func NewTeamSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyTeamSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *TeamSearchHandler {
|
||||
var teamAccessControlChecks = []teamAccessControlCheck{
|
||||
{action: "teams:read", group: iamv0alpha1.GROUP, resource: "teams", verb: utils.VerbList},
|
||||
{action: "teams:write", group: iamv0alpha1.GROUP, resource: "teams", verb: utils.VerbUpdate},
|
||||
{action: "teams:delete", group: iamv0alpha1.GROUP, resource: "teams", verb: utils.VerbDelete},
|
||||
{action: "teams.permissions:read", group: iamv0alpha1.GROUP, resource: "teams", verb: utils.VerbGetPermissions},
|
||||
{action: "teams.permissions:write", group: iamv0alpha1.GROUP, resource: "teams", verb: utils.VerbSetPermissions},
|
||||
{action: "teams.roles:read", group: iamv0alpha1.GROUP, resource: "rolebindings", verb: utils.VerbList},
|
||||
}
|
||||
|
||||
type TeamSearchHandler struct {
|
||||
log log.Logger
|
||||
client resourcepb.ResourceIndexClient
|
||||
tracer trace.Tracer
|
||||
features featuremgmt.FeatureToggles
|
||||
accessClient authlib.AccessClient
|
||||
}
|
||||
|
||||
func NewTeamSearchHandler(tracer trace.Tracer, dual dualwrite.Service, legacyTeamSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles, accessClient authlib.AccessClient) *TeamSearchHandler {
|
||||
searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0alpha1.TeamResourceInfo.GroupResource(), resourceClient, legacyTeamSearcher, features)
|
||||
|
||||
return &TeamSearchHandler{
|
||||
client: searchClient,
|
||||
log: log.New("grafana-apiserver.teams.search"),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
client: searchClient,
|
||||
log: log.New("grafana-apiserver.teams.search"),
|
||||
tracer: tracer,
|
||||
features: features,
|
||||
accessClient: accessClient,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +128,15 @@ func (s *TeamSearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinitio
|
||||
Schema: spec.Int64Property(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "accesscontrol",
|
||||
In: "query",
|
||||
Description: "when true, includes access control metadata in the response",
|
||||
Required: false,
|
||||
Schema: spec.BoolProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
@@ -194,6 +229,13 @@ func (s *TeamSearchHandler) DoTeamSearch(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if queryParams.Get("accesscontrol") == "true" && s.accessClient != nil {
|
||||
if err := s.stampAccessControl(ctx, requester, searchResults.Hits); err != nil {
|
||||
span.RecordError(err)
|
||||
s.log.Warn("failed to get access control metadata", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.write(w, searchResults); err != nil {
|
||||
s.log.Error("failed to write team search results", "error", err)
|
||||
errhttp.Write(ctx, err, w)
|
||||
@@ -201,6 +243,48 @@ func (s *TeamSearchHandler) DoTeamSearch(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *TeamSearchHandler) stampAccessControl(ctx context.Context, requester identity.Requester, hits []iamv0alpha1.GetSearchTeamsTeamHit) error {
|
||||
namespace := requester.GetNamespace()
|
||||
|
||||
items := func(yield func(teamAccessControlCheck) bool) {
|
||||
for _, hit := range hits {
|
||||
for _, c := range teamAccessControlChecks {
|
||||
c.name = hit.Name
|
||||
if !yield(c) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extractFn := func(c teamAccessControlCheck) authz.BatchCheckItem {
|
||||
return authz.BatchCheckItem{
|
||||
Verb: c.verb,
|
||||
Group: c.group,
|
||||
Resource: c.resource,
|
||||
Namespace: namespace,
|
||||
Name: c.name,
|
||||
}
|
||||
}
|
||||
|
||||
acMap := make(map[string]map[string]bool, len(hits))
|
||||
for c, err := range authz.FilterAuthorized(ctx, s.accessClient, items, extractFn, authz.WithTracer(s.tracer)) {
|
||||
if err != nil {
|
||||
return fmt.Errorf("access control check failed: %w", err)
|
||||
}
|
||||
if acMap[c.name] == nil {
|
||||
acMap[c.name] = make(map[string]bool, len(teamAccessControlChecks))
|
||||
}
|
||||
acMap[c.name][c.action] = true
|
||||
}
|
||||
|
||||
for i := range hits {
|
||||
hits[i].AccessControl = acMap[hits[i].Name]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TeamSearchHandler) write(w http.ResponseWriter, obj any) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return json.NewEncoder(w).Encode(obj)
|
||||
|
||||
@@ -2,15 +2,19 @@ package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
authlib "github.com/grafana/authlib/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -48,7 +52,7 @@ func TestTeamSearchFallback(t *testing.T) {
|
||||
},
|
||||
}
|
||||
dual := dualwrite.ProvideStaticServiceForTests(cfg)
|
||||
searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil)
|
||||
searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockLegacyClient, mockClient, nil, nil)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/teams/search", nil)
|
||||
@@ -185,7 +189,7 @@ func TestTeamSearchHandler(t *testing.T) {
|
||||
},
|
||||
}
|
||||
dual := dualwrite.ProvideStaticServiceForTests(cfg)
|
||||
searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockClient, mockClient, nil)
|
||||
searchHandler := NewTeamSearchHandler(tracing.NewNoopTracerService(), dual, mockClient, mockClient, nil, nil)
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
endpoint := fmt.Sprintf("/teams/search?limit=%d", limit)
|
||||
@@ -287,3 +291,216 @@ func (m *MockClient) UpdateIndex(ctx context.Context, reason string) error {
|
||||
func (m *MockClient) GetQuotaUsage(ctx context.Context, in *resourcepb.QuotaUsageRequest, opts ...grpc.CallOption) (*resourcepb.QuotaUsageResponse, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func mockTeamClientWithHits() *MockClient {
|
||||
return &MockClient{
|
||||
MockResponses: []*resourcepb.ResourceSearchResponse{
|
||||
{
|
||||
Results: &resourcepb.ResourceTable{
|
||||
Columns: []*resourcepb.ResourceTableColumnDefinition{
|
||||
{Name: "title"},
|
||||
},
|
||||
Rows: []*resourcepb.ResourceTableRow{
|
||||
{Key: &resourcepb.ResourceKey{Name: "team-1"}, Cells: [][]byte{[]byte("Team One")}},
|
||||
{Key: &resourcepb.ResourceKey{Name: "team-2"}, Cells: [][]byte{[]byte("Team Two")}},
|
||||
},
|
||||
},
|
||||
TotalHits: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeamAccessControl(t *testing.T) {
|
||||
partialClient := &mockTeamAccessClient{
|
||||
batchCheckFunc: func(_ context.Context, _ authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
|
||||
allowed := map[string]bool{
|
||||
"teams:read": true,
|
||||
"teams.permissions:read": true,
|
||||
"teams.roles:read": true,
|
||||
}
|
||||
results := make(map[string]authlib.BatchCheckResult, len(req.Checks))
|
||||
for _, check := range req.Checks {
|
||||
for _, c := range teamAccessControlChecks {
|
||||
if c.group == check.Group && c.resource == check.Resource && c.verb == check.Verb {
|
||||
results[check.CorrelationID] = authlib.BatchCheckResult{Allowed: allowed[c.action]}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return authlib.BatchCheckResponse{Results: results}, nil
|
||||
},
|
||||
}
|
||||
|
||||
perTeamClient := &mockTeamAccessClient{
|
||||
batchCheckFunc: func(_ context.Context, _ authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
|
||||
results := make(map[string]authlib.BatchCheckResult, len(req.Checks))
|
||||
for _, check := range req.Checks {
|
||||
allowed := false
|
||||
for _, c := range teamAccessControlChecks {
|
||||
if c.group == check.Group && c.resource == check.Resource && c.verb == check.Verb {
|
||||
if check.Name == "team-1" {
|
||||
allowed = c.action == "teams:read" || c.action == "teams:write"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
results[check.CorrelationID] = authlib.BatchCheckResult{Allowed: allowed}
|
||||
}
|
||||
return authlib.BatchCheckResponse{Results: results}, nil
|
||||
},
|
||||
}
|
||||
|
||||
errorClient := &mockTeamAccessClient{
|
||||
batchCheckFunc: func(_ context.Context, _ authlib.AuthInfo, _ authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
|
||||
return authlib.BatchCheckResponse{}, fmt.Errorf("access service unavailable")
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
client authlib.AccessClient
|
||||
checkHits func(t *testing.T, hits []iamv0alpha1.GetSearchTeamsTeamHit)
|
||||
}{
|
||||
{
|
||||
name: "param absent - no access control on hits",
|
||||
url: "/teams/search",
|
||||
client: authlib.FixedAccessClient(true),
|
||||
checkHits: func(t *testing.T, hits []iamv0alpha1.GetSearchTeamsTeamHit) {
|
||||
t.Helper()
|
||||
for _, hit := range hits {
|
||||
assert.Nil(t, hit.AccessControl)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "param false - no access control on hits",
|
||||
url: "/teams/search?accesscontrol=false",
|
||||
client: authlib.FixedAccessClient(true),
|
||||
checkHits: func(t *testing.T, hits []iamv0alpha1.GetSearchTeamsTeamHit) {
|
||||
t.Helper()
|
||||
for _, hit := range hits {
|
||||
assert.Nil(t, hit.AccessControl)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all allowed",
|
||||
url: "/teams/search?accesscontrol=true",
|
||||
client: authlib.FixedAccessClient(true),
|
||||
checkHits: func(t *testing.T, hits []iamv0alpha1.GetSearchTeamsTeamHit) {
|
||||
t.Helper()
|
||||
for _, hit := range hits {
|
||||
require.NotNil(t, hit.AccessControl)
|
||||
for _, c := range teamAccessControlChecks {
|
||||
assert.True(t, hit.AccessControl[c.action], "expected %s to be allowed", c.action)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all denied - empty map on hits",
|
||||
url: "/teams/search?accesscontrol=true",
|
||||
client: authlib.FixedAccessClient(false),
|
||||
checkHits: func(t *testing.T, hits []iamv0alpha1.GetSearchTeamsTeamHit) {
|
||||
t.Helper()
|
||||
for _, hit := range hits {
|
||||
assert.Empty(t, hit.AccessControl)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "partial permissions",
|
||||
url: "/teams/search?accesscontrol=true",
|
||||
client: partialClient,
|
||||
checkHits: func(t *testing.T, hits []iamv0alpha1.GetSearchTeamsTeamHit) {
|
||||
t.Helper()
|
||||
for _, hit := range hits {
|
||||
require.NotNil(t, hit.AccessControl)
|
||||
assert.True(t, hit.AccessControl["teams:read"])
|
||||
assert.True(t, hit.AccessControl["teams.permissions:read"])
|
||||
assert.True(t, hit.AccessControl["teams.roles:read"])
|
||||
assert.False(t, hit.AccessControl["teams:write"])
|
||||
assert.False(t, hit.AccessControl["teams:delete"])
|
||||
assert.False(t, hit.AccessControl["teams.permissions:write"])
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "per-team scoped permissions",
|
||||
url: "/teams/search?accesscontrol=true",
|
||||
client: perTeamClient,
|
||||
checkHits: func(t *testing.T, hits []iamv0alpha1.GetSearchTeamsTeamHit) {
|
||||
t.Helper()
|
||||
for _, hit := range hits {
|
||||
if hit.Name == "team-1" {
|
||||
require.NotNil(t, hit.AccessControl)
|
||||
assert.True(t, hit.AccessControl["teams:read"])
|
||||
assert.True(t, hit.AccessControl["teams:write"])
|
||||
assert.False(t, hit.AccessControl["teams:delete"])
|
||||
assert.False(t, hit.AccessControl["teams.permissions:write"])
|
||||
} else {
|
||||
assert.Empty(t, hit.AccessControl, "team-2 should have no permissions")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "access service error - graceful degradation, empty map on hits",
|
||||
url: "/teams/search?accesscontrol=true",
|
||||
client: errorClient,
|
||||
checkHits: func(t *testing.T, hits []iamv0alpha1.GetSearchTeamsTeamHit) {
|
||||
t.Helper()
|
||||
for _, hit := range hits {
|
||||
assert.Empty(t, hit.AccessControl)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
searchHandler := &TeamSearchHandler{
|
||||
log: log.New("grafana-apiserver.teams.search"),
|
||||
client: mockTeamClientWithHits(),
|
||||
tracer: tracing.NewNoopTracerService(),
|
||||
features: featuremgmt.WithFeatures(),
|
||||
accessClient: tc.client,
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", tc.url, nil)
|
||||
req.Header.Add("content-type", "application/json")
|
||||
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "default"}))
|
||||
|
||||
searchHandler.DoTeamSearch(rr, req)
|
||||
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
var resp iamv0alpha1.GetSearchTeamsResponse
|
||||
require.NoError(t, json.NewDecoder(rr.Body).Decode(&resp))
|
||||
require.Len(t, resp.Hits, 2)
|
||||
tc.checkHits(t, resp.Hits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockTeamAccessClient struct {
|
||||
batchCheckFunc func(ctx context.Context, info authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error)
|
||||
}
|
||||
|
||||
func (m *mockTeamAccessClient) Check(_ context.Context, _ authlib.AuthInfo, _ authlib.CheckRequest, _ string) (authlib.CheckResponse, error) {
|
||||
return authlib.CheckResponse{}, nil
|
||||
}
|
||||
|
||||
func (m *mockTeamAccessClient) Compile(_ context.Context, _ authlib.AuthInfo, _ authlib.ListRequest) (authlib.ItemChecker, authlib.Zookie, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (m *mockTeamAccessClient) BatchCheck(ctx context.Context, info authlib.AuthInfo, req authlib.BatchCheckRequest) (authlib.BatchCheckResponse, error) {
|
||||
if m.batchCheckFunc != nil {
|
||||
return m.batchCheckFunc(ctx, info, req)
|
||||
}
|
||||
return authlib.BatchCheckResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -198,3 +199,84 @@ func doTeamSearchTests(t *testing.T, helper *apis.K8sTestHelper) {
|
||||
require.Equal(t, int64(1), result.Offset, "should return offset 1")
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationTeamSearch_AccessControl(t *testing.T) {
|
||||
testutil.SkipIntegrationTestInShortMode(t)
|
||||
|
||||
modes := []rest.DualWriterMode{rest.Mode0, rest.Mode1, rest.Mode2, rest.Mode3, rest.Mode4, rest.Mode5}
|
||||
for _, mode := range modes {
|
||||
t.Run(fmt.Sprintf("DualWriterMode %d", mode), func(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
AppModeProduction: false,
|
||||
DisableAnonymous: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
"teams.iam.grafana.app": {
|
||||
DualWriterMode: mode,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs,
|
||||
featuremgmt.FlagKubernetesAuthnMutation,
|
||||
},
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
helper.Shutdown()
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
namespace := helper.Namespacer(helper.Org1.Admin.Identity.GetOrgID())
|
||||
|
||||
teamClient := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
Namespace: namespace,
|
||||
GVR: gvrTeams,
|
||||
})
|
||||
|
||||
team1, err := teamClient.Resource.Create(ctx, helper.LoadYAMLOrJSONFile("testdata/team-test-create-v0.yaml"), metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, team1)
|
||||
|
||||
t.Run("accesscontrol=true includes permissions on hits", func(t *testing.T) {
|
||||
res := searchTeamsWithAccessControl(t, helper, namespace, "", true)
|
||||
require.GreaterOrEqual(t, len(res.Hits), 1)
|
||||
for _, hit := range res.Hits {
|
||||
require.NotNil(t, hit.AccessControl, "expected AccessControl map on hit %s", hit.Name)
|
||||
require.True(t, hit.AccessControl["teams:read"], "admin should have teams:read on %s", hit.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accesscontrol absent omits permissions from hits", func(t *testing.T) {
|
||||
res := searchTeamsWithAccessControl(t, helper, namespace, "", false)
|
||||
require.GreaterOrEqual(t, len(res.Hits), 1)
|
||||
for _, hit := range res.Hits {
|
||||
require.Empty(t, hit.AccessControl, "expected no AccessControl on hit %s when param absent", hit.Name)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func searchTeamsWithAccessControl(t *testing.T, helper *apis.K8sTestHelper, namespace string, query string, accessControl bool) *iamv0alpha1.GetSearchTeamsResponse {
|
||||
q := url.Values{}
|
||||
if query != "" {
|
||||
q.Set("query", query)
|
||||
}
|
||||
q.Set("limit", "100")
|
||||
if accessControl {
|
||||
q.Set("accesscontrol", "true")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/apis/iam.grafana.app/v0alpha1/namespaces/%s/searchTeams?%s", namespace, q.Encode())
|
||||
|
||||
res := &iamv0alpha1.GetSearchTeamsResponse{}
|
||||
rsp := apis.DoRequest(helper, apis.RequestParams{
|
||||
User: helper.Org1.Admin,
|
||||
Method: http.MethodGet,
|
||||
Path: path,
|
||||
}, res)
|
||||
|
||||
require.Equal(t, 200, rsp.Response.StatusCode)
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -986,6 +986,14 @@
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "accesscontrol",
|
||||
"in": "query",
|
||||
"description": "when true, includes access control metadata in the response",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
@@ -5147,6 +5155,13 @@
|
||||
"externalUID"
|
||||
],
|
||||
"properties": {
|
||||
"accessControl": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
|
||||
Reference in New Issue
Block a user