diff --git a/apps/iam/kinds/user.cue b/apps/iam/kinds/user.cue index 602500943ee..e4c8ca0e410 100644 --- a/apps/iam/kinds/user.cue +++ b/apps/iam/kinds/user.cue @@ -38,9 +38,9 @@ userv0alpha1: userKind & { "GET": { response: { #UserTeam: { - title: string teamRef: v0alpha1.TeamRef permission: v0alpha1.TeamPermission + external: bool } items: [...#UserTeam] } diff --git a/apps/iam/pkg/apis/iam/v0alpha1/user_getteams_response_body_types_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/user_getteams_response_body_types_gen.go index 56504c6abb9..1ff1696d7db 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/user_getteams_response_body_types_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/user_getteams_response_body_types_gen.go @@ -4,9 +4,9 @@ package v0alpha1 // +k8s:openapi-gen=true type VersionsV0alpha1Kinds6RoutesTeamsGETResponseUserTeam struct { - Title string `json:"title"` TeamRef TeamRef `json:"teamRef"` Permission TeamPermission `json:"permission"` + External bool `json:"external"` } // NewVersionsV0alpha1Kinds6RoutesTeamsGETResponseUserTeam creates a new VersionsV0alpha1Kinds6RoutesTeamsGETResponseUserTeam object. diff --git a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go index 9255a733b28..1aae2d7384d 100644 --- a/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go +++ b/apps/iam/pkg/apis/iam/v0alpha1/zz_openapi_gen.go @@ -3219,13 +3219,6 @@ func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds6RoutesTeamsGETResponseUs SchemaProps: spec.SchemaProps{ Type: []string{"object"}, Properties: map[string]spec.Schema{ - "title": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, "teamRef": { SchemaProps: spec.SchemaProps{ Default: map[string]interface{}{}, @@ -3239,8 +3232,15 @@ func schema_pkg_apis_iam_v0alpha1_VersionsV0alpha1Kinds6RoutesTeamsGETResponseUs Format: "", }, }, + "external": { + SchemaProps: spec.SchemaProps{ + Default: false, + Type: []string{"boolean"}, + Format: "", + }, + }, }, - Required: []string{"title", "teamRef", "permission"}, + Required: []string{"teamRef", "permission", "external"}, }, }, Dependencies: []string{ diff --git a/apps/iam/pkg/app/app.go b/apps/iam/pkg/app/app.go index 6ce211a2622..48a2cd85a2f 100644 --- a/apps/iam/pkg/app/app.go +++ b/apps/iam/pkg/app/app.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/trace" "github.com/grafana/grafana-app-sdk/app" "github.com/grafana/grafana-app-sdk/logging" @@ -16,6 +17,9 @@ import ( "github.com/grafana/grafana/apps/iam/pkg/reconcilers" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/services/authz" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + res "github.com/grafana/grafana/pkg/storage/unified/resource" ) var appManifestData = app.ManifestData{ @@ -28,11 +32,15 @@ type InformerConfig struct { } type AppConfig struct { + Tracer trace.Tracer ZanzanaClientCfg authz.ZanzanaClientConfig InformerConfig InformerConfig Namespace string MetricsRegisterer prometheus.Registerer LegacyStore legacy.LegacyIdentityStore + Dual dualwrite.Service + Features featuremgmt.FeatureToggles + Unified res.ResourceClient } func Provider(appCfg app.SpecificConfig) app.Provider { @@ -89,6 +97,8 @@ func New(cfg app.Config) (app.App, error) { logging.DefaultLogger.Info("FolderReconciler created") + userTeamsHandler := NewGetTeamsHandler(appSpecificConfig.Tracer, appSpecificConfig.Dual, nil, appSpecificConfig.Unified, appSpecificConfig.Features) + config := simple.AppConfig{ Name: cfg.ManifestData.AppName, KubeConfig: cfg.KubeConfig, @@ -111,7 +121,7 @@ func New(cfg app.Config) (app.App, error) { { Path: "teams", Method: "GET", - }: GetTeamsHandler(appSpecificConfig.LegacyStore), + }: userTeamsHandler.Handle, }, }, }, diff --git a/apps/iam/pkg/app/routes.go b/apps/iam/pkg/app/routes.go deleted file mode 100644 index 45ba1893b96..00000000000 --- a/apps/iam/pkg/app/routes.go +++ /dev/null @@ -1,47 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - - "github.com/grafana/grafana-app-sdk/app" - "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" - "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" -) - -// GetTeamsHandler handles requests for the GET /teams subresource route on User -func GetTeamsHandler(store legacy.LegacyIdentityStore) func(ctx context.Context, writer app.CustomRouteResponseWriter, req *app.CustomRouteRequest) error { - return func(ctx context.Context, writer app.CustomRouteResponseWriter, req *app.CustomRouteRequest) error { - userUID := req.ResourceIdentifier.Name - - ns, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return err - } - - result, err := store.ListUserTeams(ctx, ns, legacy.ListUserTeamsQuery{ - UserUID: userUID, - Pagination: common.PaginationFromListQuery(req.URL.Query()), - }) - if err != nil { - return err - } - - items := make([]v0alpha1.VersionsV0alpha1Kinds6RoutesTeamsGETResponseUserTeam, len(result.Items)) - for i, team := range result.Items { - items[i] = v0alpha1.VersionsV0alpha1Kinds6RoutesTeamsGETResponseUserTeam{ - Title: team.Name, - TeamRef: v0alpha1.TeamRef{Name: team.UID}, - Permission: v0alpha1.TeamPermission(team.Permission.String()), - } - } - - return json.NewEncoder(writer).Encode(v0alpha1.GetTeams{ - GetTeamsBody: v0alpha1.GetTeamsBody{ - Items: items, - }, - }) - } -} diff --git a/apps/iam/pkg/app/user_teams.go b/apps/iam/pkg/app/user_teams.go new file mode 100644 index 00000000000..89d58dff342 --- /dev/null +++ b/apps/iam/pkg/app/user_teams.go @@ -0,0 +1,151 @@ +package app + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + + "go.opentelemetry.io/otel/trace" + + "github.com/grafana/grafana-app-sdk/app" + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +type GetTeamsHandler struct { + log log.Logger + client resourcepb.ResourceIndexClient + tracer trace.Tracer + features featuremgmt.FeatureToggles +} + +func NewGetTeamsHandler(tracer trace.Tracer, dual dualwrite.Service, legacyTeamSearcher resourcepb.ResourceIndexClient, resourceClient resource.ResourceClient, features featuremgmt.FeatureToggles) *GetTeamsHandler { + searchClient := resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0alpha1.TeamBindingResourceInfo.GroupResource(), resourceClient, legacyTeamSearcher, features) + + return &GetTeamsHandler{ + client: searchClient, + log: log.New("grafana-apiserver.teams.search"), + tracer: tracer, + features: features, + } +} + +func (h *GetTeamsHandler) Handle(ctx context.Context, writer app.CustomRouteResponseWriter, request *app.CustomRouteRequest) error { + ctx, span := h.tracer.Start(ctx, "user.teams") + defer span.End() + + queryParams, err := url.ParseQuery(request.URL.RawQuery) + if err != nil { + return err + } + + requester, err := identity.GetRequester(ctx) + if err != nil { + return fmt.Errorf("no identity found for request: %w", err) + } + + limit := 50 + offset := 0 + page := 1 + if queryParams.Has("limit") { + limit, _ = strconv.Atoi(queryParams.Get("limit")) + } + if queryParams.Has("offset") { + offset, _ = strconv.Atoi(queryParams.Get("offset")) + if offset > 0 { + page = (offset / limit) + 1 + } + } else if queryParams.Has("page") { + page, _ = strconv.Atoi(queryParams.Get("page")) + offset = (page - 1) * limit + } + + searchRequest := &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Group: iamv0alpha1.TeamBindingResourceInfo.GroupResource().Group, + Resource: iamv0alpha1.TeamBindingResourceInfo.GroupResource().Resource, + Namespace: requester.GetNamespace(), + }, + }, + Limit: int64(limit), + Offset: int64(offset), + Page: int64(page), + Explain: queryParams.Has("explain") && queryParams.Get("explain") != "false", + Fields: []string{ + resource.SEARCH_FIELD_PREFIX + "teamRef.name", + resource.SEARCH_FIELD_PREFIX + "permission", + resource.SEARCH_FIELD_PREFIX + "external", + }, + } + + result, err := h.client.Search(ctx, searchRequest) + if err != nil { + return err + } + + searchResults, err := h.parseResults(result, searchRequest.Offset) + if err != nil { + return err + } + + if err := json.NewEncoder(writer).Encode(searchResults); err != nil { + return err + } + + return nil +} + +func (h *GetTeamsHandler) parseResults(result *resourcepb.ResourceSearchResponse, offset int64) (iamv0alpha1.GetTeamsBody, error) { + if result == nil { + return iamv0alpha1.GetTeamsBody{}, nil + } else if result.Error != nil { + return iamv0alpha1.GetTeamsBody{}, fmt.Errorf("%d error searching: %s: %s", result.Error.Code, result.Error.Message, result.Error.Details) + } else if result.Results == nil { + return iamv0alpha1.GetTeamsBody{}, nil + } + + teamRefIDX := -1 + permissionIDX := -1 + externalIDX := -1 + + for i, v := range result.Results.Columns { + if v == nil { + continue + } + + switch v.Name { + case "teamRef.name": + teamRefIDX = i + case "permission": + permissionIDX = i + case "external": + externalIDX = i + } + } + + body := iamv0alpha1.GetTeamsBody{ + Items: make([]iamv0alpha1.VersionsV0alpha1Kinds6RoutesTeamsGETResponseUserTeam, len(result.Results.Rows)), + } + + for i, row := range result.Results.Rows { + if len(row.Cells) != len(result.Results.Columns) { + return iamv0alpha1.GetTeamsBody{}, fmt.Errorf("error parsing team binding response: mismatch number of columns and cells") + } + + body.Items[i] = iamv0alpha1.VersionsV0alpha1Kinds6RoutesTeamsGETResponseUserTeam{ + TeamRef: iamv0alpha1.TeamRef{Name: string(row.Cells[teamRefIDX])}, + Permission: iamv0alpha1.TeamPermission(string(row.Cells[permissionIDX])), + External: string(row.Cells[externalIDX]) == "true", + } + } + + return body, nil +}