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:
Georges Chaudy
2026-03-12 13:11:35 +01:00
committed by GitHub
parent 616f5aafc5
commit 03b4e06128
11 changed files with 463 additions and 18 deletions

View File

@@ -68,6 +68,7 @@ v0alpha1: {
email: string
provisioned: bool
externalUID: string
accessControl?: {[string]: bool}
}
offset: int64
totalHits: int64

View File

@@ -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.

View File

@@ -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"},
},

View File

@@ -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"},

View File

@@ -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 = {

View File

@@ -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": ""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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": ""