From ad276a842ad36ef81f5cd4b24faf055ac85c4f1d Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 5 Sep 2025 16:53:20 +0100 Subject: [PATCH 01/33] Add ACLs for workload identity --- lib/auth/authclient/clt.go | 7 +++++++ lib/services/useracl.go | 4 ++++ web/packages/teleport/src/mocks/contexts.ts | 1 + web/packages/teleport/src/services/resources/types.ts | 1 + web/packages/teleport/src/services/user/makeAcl.ts | 3 +++ web/packages/teleport/src/services/user/types.ts | 1 + web/packages/teleport/src/services/user/user.test.ts | 7 +++++++ web/packages/teleport/src/stores/storeUserContext.ts | 4 ++++ web/packages/teleport/src/teleportContext.tsx | 2 ++ web/packages/teleport/src/types.ts | 1 + 10 files changed, 31 insertions(+) diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go index 5cc2e827a8a88..f8079d99359e1 100644 --- a/lib/auth/authclient/clt.go +++ b/lib/auth/authclient/clt.go @@ -1741,6 +1741,13 @@ type ClientI interface { // (as per the default gRPC behavior). BotInstanceServiceClient() machineidv1pb.BotInstanceServiceClient + // WorkloadIdentityResourceServiceClient returns a client for interacting + // with workload identity resources. + // Clients connecting to older Teleport versions, still get an access list + // client when calling this method, but all RPCs will return "not + // implemented" errors (as per the default gRPC behavior). + WorkloadIdentityResourceServiceClient() workloadidentityv1pb.WorkloadIdentityResourceServiceClient + // UserLoginStateClient returns a user login state client. // Clients connecting to older Teleport versions still get a user login state client // when calling this method, but all RPCs will return "not implemented" errors diff --git a/lib/services/useracl.go b/lib/services/useracl.go index 77602945e338a..d8c312bd39c60 100644 --- a/lib/services/useracl.go +++ b/lib/services/useracl.go @@ -122,6 +122,8 @@ type UserACL struct { FileTransferAccess bool `json:"fileTransferAccess"` // GitServers defines access to Git servers. GitServers ResourceAccess `json:"gitServers"` + //WorkloadIdentity defines access to Workload Identity + WorkloadIdentity ResourceAccess `json:"workloadIdentity"` } func hasAccess(roleSet RoleSet, ctx *Context, kind string, verbs ...string) bool { @@ -217,6 +219,7 @@ func NewUserACL(user types.User, userRoles RoleSet, features proto.Features, des userTasksAccess := newAccess(userRoles, ctx, types.KindUserTask) reviewRequests := userRoles.MaybeCanReviewRequests() fileTransferAccess := userRoles.CanCopyFiles() + workloadIdentity := newAccess(userRoles, ctx, types.KindWorkloadIdentity) var auditQuery ResourceAccess var securityReports ResourceAccess @@ -271,5 +274,6 @@ func NewUserACL(user types.User, userRoles RoleSet, features proto.Features, des Contact: contact, FileTransferAccess: fileTransferAccess, GitServers: gitServersAccess, + WorkloadIdentity: workloadIdentity, } } diff --git a/web/packages/teleport/src/mocks/contexts.ts b/web/packages/teleport/src/mocks/contexts.ts index eea39f5e93df7..beac4c7c28e9c 100644 --- a/web/packages/teleport/src/mocks/contexts.ts +++ b/web/packages/teleport/src/mocks/contexts.ts @@ -79,6 +79,7 @@ export const allAccessAcl: Acl = { gitServers: fullAccess, accessGraphSettings: fullAccess, botInstances: fullAccess, + workloadIdentity: fullAccess, }; export function getAcl(cfg?: { noAccess: boolean }) { diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index 77d67359bf4ef..2bcebee342fbe 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -303,6 +303,7 @@ export enum ResourceKind { WebToken = 'web_token', WindowsDesktop = 'windows_desktop', WindowsDesktopService = 'windows_desktop_service', + KindWorkloadIdentity = 'workload_identity', // Resources that have no actual data representation, but serve for checking // access to various features. diff --git a/web/packages/teleport/src/services/user/makeAcl.ts b/web/packages/teleport/src/services/user/makeAcl.ts index 03863cf1bb45d..b0ee071d0a7c9 100644 --- a/web/packages/teleport/src/services/user/makeAcl.ts +++ b/web/packages/teleport/src/services/user/makeAcl.ts @@ -86,6 +86,8 @@ export function makeAcl(json): Acl { const botInstances = json.botInstances || defaultAccess; + const workloadIdentity = json.workloadIdentity || defaultAccess; + return { accessList, authConnectors, @@ -128,6 +130,7 @@ export function makeAcl(json): Acl { gitServers, accessGraphSettings, botInstances, + workloadIdentity, }; } diff --git a/web/packages/teleport/src/services/user/types.ts b/web/packages/teleport/src/services/user/types.ts index 145209cb29688..2f6f2914f1345 100644 --- a/web/packages/teleport/src/services/user/types.ts +++ b/web/packages/teleport/src/services/user/types.ts @@ -113,6 +113,7 @@ export interface Acl { gitServers: Access; accessGraphSettings: Access; botInstances: Access; + workloadIdentity: Access; } // AllTraits represent all the traits defined for a user. diff --git a/web/packages/teleport/src/services/user/user.test.ts b/web/packages/teleport/src/services/user/user.test.ts index 50269b6975dc7..495eb5edc5ea3 100644 --- a/web/packages/teleport/src/services/user/user.test.ts +++ b/web/packages/teleport/src/services/user/user.test.ts @@ -310,6 +310,13 @@ test('undefined values in context response gives proper default values', async ( create: false, remove: false, }, + workloadIdentity: { + list: false, + read: false, + edit: false, + create: false, + remove: false, + }, }; expect(response).toEqual({ diff --git a/web/packages/teleport/src/stores/storeUserContext.ts b/web/packages/teleport/src/stores/storeUserContext.ts index fd23423d74cf7..0f00c8b9a868e 100644 --- a/web/packages/teleport/src/stores/storeUserContext.ts +++ b/web/packages/teleport/src/stores/storeUserContext.ts @@ -270,4 +270,8 @@ export default class StoreUserContext extends Store { getGitServersAccess() { return this.state.acl.gitServers; } + + getWorkloadIdentityAccess() { + return this.state.acl.workloadIdentity; + } } diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx index 8d73ba4e24f9b..b265b5314d35d 100644 --- a/web/packages/teleport/src/teleportContext.tsx +++ b/web/packages/teleport/src/teleportContext.tsx @@ -224,6 +224,7 @@ class TeleportContext implements types.Context { userContext.getGitServersAccess().list && userContext.getGitServersAccess().read, listBotInstances: userContext.getBotInstancesAccess().list, + listWorkloadIdentities: userContext.getWorkloadIdentityAccess().list, }; } } @@ -268,6 +269,7 @@ export const disabledFeatureFlags: types.FeatureFlags = { removeBots: false, gitServers: false, listBotInstances: false, + listWorkloadIdentities: false, }; export default TeleportContext; diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index 3a1b66b931ba0..e377294ce2af6 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -212,6 +212,7 @@ export interface FeatureFlags { editBots: boolean; removeBots: boolean; gitServers: boolean; + listWorkloadIdentities: boolean; } // LockedFeatures are used for determining which features are disabled in the user's cluster. From 4ae65c01344a84c217cdeacd81feab9c3606862f Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 5 Sep 2025 17:07:17 +0100 Subject: [PATCH 02/33] Add list workload identities to webapi --- lib/web/apiserver.go | 3 + lib/web/workload_identity.go | 84 +++++++++ lib/web/workload_identity_test.go | 168 ++++++++++++++++++ web/packages/teleport/src/config.ts | 21 +++ .../src/services/workloadIdentity/consts.ts | 37 ++++ .../src/services/workloadIdentity/types.ts | 29 +++ .../workloadIdentity/workloadIdentity.ts | 46 +++++ .../teleport/src/test/helpers/botInstances.ts | 17 +- .../src/test/helpers/workloadIdentities.ts | 74 ++++++++ 9 files changed, 468 insertions(+), 11 deletions(-) create mode 100644 lib/web/workload_identity.go create mode 100644 lib/web/workload_identity_test.go create mode 100644 web/packages/teleport/src/services/workloadIdentity/consts.ts create mode 100644 web/packages/teleport/src/services/workloadIdentity/types.ts create mode 100644 web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts create mode 100644 web/packages/teleport/src/test/helpers/workloadIdentities.ts diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 225e157e32d0f..474b0f2ddd4a7 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1166,6 +1166,9 @@ func (h *Handler) bindDefaultEndpoints() { // GET Machine ID bot instances (paged) h.GET("/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstances)) + // List workload identities + h.GET("/webapi/sites/:site/workload-identity", h.WithClusterAuth(h.listWorkloadIdentities)) + // GET a paginated list of notifications for a user h.GET("/webapi/sites/:site/notifications", h.WithClusterAuth(h.notificationsGet)) // Upsert the timestamp of the latest notification that the user has seen. diff --git a/lib/web/workload_identity.go b/lib/web/workload_identity.go new file mode 100644 index 0000000000000..061ce59937e6c --- /dev/null +++ b/lib/web/workload_identity.go @@ -0,0 +1,84 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "net/http" + "strconv" + + "github.com/gravitational/trace" + "github.com/julienschmidt/httprouter" + + workloadidentityv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/lib/reversetunnelclient" + tslices "github.com/gravitational/teleport/lib/utils/slices" +) + +// listWorkloadIdentities returns a list of workload identities for a given +// cluster site. +func (h *Handler) listWorkloadIdentities(_ http.ResponseWriter, r *http.Request, _ httprouter.Params, sctx *SessionContext, cluster reversetunnelclient.Cluster) (any, error) { + clt, err := sctx.GetUserClient(r.Context(), cluster) + if err != nil { + return nil, trace.Wrap(err) + } + + var pageSize int64 = 20 + if r.URL.Query().Has("page_size") { + pageSize, err = strconv.ParseInt(r.URL.Query().Get("page_size"), 10, 32) + if err != nil { + return nil, trace.BadParameter("invalid page size") + } + } + + result, err := clt.WorkloadIdentityResourceServiceClient().ListWorkloadIdentities(r.Context(), &workloadidentityv1.ListWorkloadIdentitiesRequest{ + PageSize: int32(pageSize), + PageToken: r.URL.Query().Get("page_token"), + }) + if err != nil { + return nil, trace.Wrap(err) + } + + uiItems := tslices.Map(result.WorkloadIdentities, func(item *workloadidentityv1.WorkloadIdentity) WorkloadIdentity { + uiItem := WorkloadIdentity{ + Name: item.Metadata.Name, + SpiffeID: item.Spec.Spiffe.Id, + SpiffeHint: item.Spec.Spiffe.Hint, + Labels: item.Metadata.Labels, + } + + return uiItem + }) + + return ListWorkloadIdentitiesResponse{ + Items: uiItems, + NextPageToken: result.NextPageToken, + }, nil +} + +type ListWorkloadIdentitiesResponse struct { + Items []WorkloadIdentity `json:"items"` + NextPageToken string `json:"next_page_token,omitempty"` +} + +type WorkloadIdentity struct { + Name string `json:"name"` + SpiffeID string `json:"spiffe_id"` + SpiffeHint string `json:"spiffe_hint"` + Labels map[string]string `json:"labels"` +} diff --git a/lib/web/workload_identity_test.go b/lib/web/workload_identity_test.go new file mode 100644 index 0000000000000..0ff12803d780a --- /dev/null +++ b/lib/web/workload_identity_test.go @@ -0,0 +1,168 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "context" + "encoding/json" + "math" + "net/http" + "net/url" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" + workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/services" +) + +func TestListWorkloadIdentities(t *testing.T) { + t.Parallel() + + ctx := context.Background() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoint := pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "workload-identity", + ) + + name := uuid.New().String() + + _, err := env.server.Auth().CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: name, + Labels: map[string]string{ + "label-1": "value-1", + "label-2": "value-2", + }, + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/test/spiffe/id", + Hint: "Lorem ipsum delor sit", + }, + }, + }) + require.NoError(t, err) + + response, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var instances ListWorkloadIdentitiesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &instances), "invalid response received") + + assert.Len(t, instances.Items, 1) + require.Empty(t, cmp.Diff(instances, ListWorkloadIdentitiesResponse{ + Items: []WorkloadIdentity{ + { + Name: name, + SpiffeID: "/test/spiffe/id", + SpiffeHint: "Lorem ipsum delor sit", + Labels: map[string]string{ + "label-1": "value-1", + "label-2": "value-2", + }, + }, + }, + })) +} + +func TestListWorkloadIdentitiesPaging(t *testing.T) { + t.Parallel() + + tcs := []struct { + name string + numInstances int + pageSize int + }{ + { + name: "zero results", + numInstances: 0, + pageSize: 1, + }, + { + name: "smaller page size", + numInstances: 5, + pageSize: 2, + }, + { + name: "larger page size", + numInstances: 2, + pageSize: 5, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoint := pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "workload-identity", + ) + + for range tc.numInstances { + _, err := env.server.Auth().CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: uuid.New().String(), + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/test/spiffe/" + uuid.New().String(), + }, + }, + }) + require.NoError(t, err) + } + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "page_token": []string{""}, // default to the start + "page_size": []string{strconv.Itoa(tc.pageSize)}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var resp ListWorkloadIdentitiesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") + + assert.Len(t, resp.Items, int(math.Min(float64(tc.numInstances), float64(tc.pageSize)))) + }) + } +} diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 4c397aec2d830..de559f64839fd 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -503,6 +503,10 @@ const cfg = { list: '/v1/webapi/sites/:clusterId/machine-id/bot-instance', }, + workloadIdentity: { + list: '/v1/webapi/sites/:clusterId/workload-identity', + }, + gcpWorkforceConfigurePath: '/v1/webapi/scripts/integrations/configure/gcp-workforce-saml.sh?orgId=:orgId&poolName=:poolName&poolProviderName=:poolProviderName', @@ -1686,6 +1690,23 @@ const cfg = { } }, + getWorkloadIdentityUrl( + req: { + action: 'list'; + } & { clusterId?: string } + ) { + const { clusterId = cfg.proxyCluster } = req; + switch (req.action) { + case 'list': + return generatePath(cfg.api.workloadIdentity.list, { + clusterId, + }); + default: + req.action satisfies never; + return ''; + } + }, + getGcpWorkforceConfigScriptUrl(p: UrlGcpWorkforceConfigParam) { return ( cfg.baseUrl + generatePath(cfg.api.gcpWorkforceConfigurePath, { ...p }) diff --git a/web/packages/teleport/src/services/workloadIdentity/consts.ts b/web/packages/teleport/src/services/workloadIdentity/consts.ts new file mode 100644 index 0000000000000..a4393c0871193 --- /dev/null +++ b/web/packages/teleport/src/services/workloadIdentity/consts.ts @@ -0,0 +1,37 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ListWorkloadIdentitiesResponse } from './types'; + +export function validateListWorkloadIdentitiesResponse( + data: unknown +): data is ListWorkloadIdentitiesResponse { + if (typeof data !== 'object' || data === null) { + return false; + } + + if (!('items' in data)) { + return false; + } + + if (!Array.isArray(data.items)) { + return false; + } + + return data.items.every(x => typeof x === 'object' || x !== null); +} diff --git a/web/packages/teleport/src/services/workloadIdentity/types.ts b/web/packages/teleport/src/services/workloadIdentity/types.ts new file mode 100644 index 0000000000000..6f07b65559215 --- /dev/null +++ b/web/packages/teleport/src/services/workloadIdentity/types.ts @@ -0,0 +1,29 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export type ListWorkloadIdentitiesResponse = { + items: WorkloadIdentity[] | null; + next_page_token: string | null | undefined; +}; + +export type WorkloadIdentity = { + name: string; + spiffe_id: string | null | undefined; + labels: Record | null | undefined; + spiffe_hint: string | null | undefined; +}; diff --git a/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts new file mode 100644 index 0000000000000..69c2dfa47d6cd --- /dev/null +++ b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts @@ -0,0 +1,46 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import cfg from 'teleport/config'; + +import api from '../api/api'; +import { validateListWorkloadIdentitiesResponse } from './consts'; + +export async function listWorkloadIdentities( + variables: { + pageToken: string; + pageSize: number; + }, + signal?: AbortSignal +) { + const { pageToken, pageSize } = variables; + + const path = cfg.getWorkloadIdentityUrl({ action: 'list' }); + const qs = new URLSearchParams(); + + qs.set('page_size', pageSize.toFixed()); + qs.set('page_token', pageToken); + + const data = await api.get(`${path}?${qs.toString()}`, signal); + + if (!validateListWorkloadIdentitiesResponse(data)) { + throw new Error('failed to validate list workload identities response'); + } + + return data; +} diff --git a/web/packages/teleport/src/test/helpers/botInstances.ts b/web/packages/teleport/src/test/helpers/botInstances.ts index 4271587f501fa..475aa0ab02088 100644 --- a/web/packages/teleport/src/test/helpers/botInstances.ts +++ b/web/packages/teleport/src/test/helpers/botInstances.ts @@ -18,25 +18,20 @@ import { http, HttpResponse } from 'msw'; +import cfg from 'teleport/config'; import { GetBotInstanceResponse, ListBotInstancesResponse, } from 'teleport/services/bot/types'; -const listBotInstancesPath = - '/v1/webapi/sites/:cluster_id/machine-id/bot-instance'; - -const getBotInstancePath = - '/v1/webapi/sites/:cluster_id/machine-id/bot/:bot_name/bot-instance/:id'; - export const listBotInstancesSuccess = (mock: ListBotInstancesResponse) => - http.get(listBotInstancesPath, () => { + http.get(cfg.api.botInstance.list, () => { return HttpResponse.json(mock); }); export const listBotInstancesForever = () => http.get( - listBotInstancesPath, + cfg.api.botInstance.list, () => new Promise(() => { /* never resolved */ @@ -47,16 +42,16 @@ export const listBotInstancesError = ( status: number, error: string | null = null ) => - http.get(listBotInstancesPath, () => { + http.get(cfg.api.botInstance.list, () => { return HttpResponse.json({ error: { message: error } }, { status }); }); export const getBotInstanceSuccess = (mock: GetBotInstanceResponse) => - http.get(getBotInstancePath, () => { + http.get(cfg.api.botInstance.read, () => { return HttpResponse.json(mock); }); export const getBotInstanceError = (status: number) => - http.get(getBotInstancePath, () => { + http.get(cfg.api.botInstance.read, () => { return new HttpResponse(null, { status }); }); diff --git a/web/packages/teleport/src/test/helpers/workloadIdentities.ts b/web/packages/teleport/src/test/helpers/workloadIdentities.ts new file mode 100644 index 0000000000000..a75227bc06a03 --- /dev/null +++ b/web/packages/teleport/src/test/helpers/workloadIdentities.ts @@ -0,0 +1,74 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { http, HttpResponse } from 'msw'; + +import cfg from 'teleport/config'; +import { ListWorkloadIdentitiesResponse } from 'teleport/services/workloadIdentity/types'; + +export const listWorkloadIdentitiesSuccess = ( + mock: ListWorkloadIdentitiesResponse = { + items: [ + { + name: 'test-workload-identity-1', + spiffe_id: '/test/spiffe/abb53fc8-eba6-40a9-8801-221db41f3c21', + spiffe_hint: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + labels: { + 'test-label-1': 'test-value-1', + 'test-label-2': 'test-value-2', + 'test-label-3': 'test-value-3', + }, + }, + { + name: 'test-workload-identity-2', + spiffe_id: '', + spiffe_hint: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + labels: {}, + }, + { + name: 'test-workload-identity-3', + spiffe_id: '/test/spiffe/6bfd8c2d-83eb-4a6f-97ba-f8b187f08339', + spiffe_hint: '', + labels: { 'test-label-1': 'test-value-1' }, + }, + ], + next_page_token: 'page-token-1', + } +) => + http.get(cfg.api.workloadIdentity.list, () => { + return HttpResponse.json(mock); + }); + +export const listWorkloadIdentitiesForever = () => + http.get( + cfg.api.workloadIdentity.list, + () => + new Promise(() => { + /* never resolved */ + }) + ); + +export const listWorkloadIdentitiesError = ( + status: number, + error: string | null = null +) => + http.get(cfg.api.workloadIdentity.list, () => { + return HttpResponse.json({ error: { message: error } }, { status }); + }); From ec33356c9f16c579cd2d28931cea5580543b6e6b Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 8 Sep 2025 14:53:24 +0100 Subject: [PATCH 03/33] Add `newWebPackWithOptions` --- lib/web/apiserver_test.go | 46 ++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index 763fd1ee0e0b9..5d89fe0804129 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -8120,16 +8120,46 @@ func decodeSessionCookie(t *testing.T, value string) (sessionID string) { return cookie.SessionID } +type WebPackOptions struct { + // Number of proxies to setup (default: 1) + numProxies int + opts []proxyOption + enableAuthCache bool +} + +func (o *WebPackOptions) setDefaultOptions() { + if o == nil { + return + } + if o.numProxies <= 0 { + o.numProxies = 1 + } +} + +// Deprecated: use newWebPackWithOptions instead +// +// TODO(nicholasmarais1158): Replace uses of this function, then rename `newWebPackWithOptions` to `newWebPack`. func newWebPack(t *testing.T, numProxies int, opts ...proxyOption) *webPack { + return newWebPackWithOptions(t, &WebPackOptions{ + numProxies: numProxies, + opts: opts, + enableAuthCache: false, + }) +} + +func newWebPackWithOptions(t *testing.T, options *WebPackOptions) *webPack { + options.setDefaultOptions() + ctx := context.Background() clock := clockwork.NewFakeClockAt(time.Now()) server, err := authtest.NewTestServer(authtest.ServerConfig{ Auth: authtest.AuthServerConfig{ - ClusterName: "localhost", - Dir: t.TempDir(), - Clock: clock, - AuditLog: events.NewDiscardAuditLog(), + ClusterName: "localhost", + Dir: t.TempDir(), + Clock: clock, + AuditLog: events.NewDiscardAuditLog(), + CacheEnabled: options.enableAuthCache, }, }) require.NoError(t, err) @@ -8240,20 +8270,20 @@ func newWebPack(t *testing.T, numProxies int, opts ...proxyOption) *webPack { }) var proxies []*testProxy - for p := range numProxies { + for p := range options.numProxies { proxyID := fmt.Sprintf("proxy%v", p) - proxies = append(proxies, createProxy(ctx, t, proxyID, node, server.TLS, hostSigners, clock, opts...)) + proxies = append(proxies, createProxy(ctx, t, proxyID, node, server.TLS, hostSigners, clock, options.opts...)) } // Wait for proxies to fully register before starting the test. for start := time.Now(); ; { proxies, err := proxies[0].client.GetProxies() require.NoError(t, err) - if len(proxies) == numProxies { + if len(proxies) == options.numProxies { break } if time.Since(start) > 5*time.Second { - t.Fatalf("Proxies didn't register within 5s after startup; registered: %d, want: %d", len(proxies), numProxies) + t.Fatalf("Proxies didn't register within 5s after startup; registered: %d, want: %d", len(proxies), options.numProxies) } } From 9f81a8606af3580db4135c7c915c7802ef4af332 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 8 Sep 2025 14:52:49 +0100 Subject: [PATCH 04/33] Add sorting by name and spiffe id --- .../v1/resource_service.pb.go | 59 ++++--- .../v1/resource_service.proto | 3 + lib/auth/authclient/api.go | 2 +- .../workloadidentityv1/issuer_service.go | 2 +- .../workloadidentityv1/resource_service.go | 6 +- lib/cache/bot_instance.go | 66 ++++---- lib/cache/workload_identity.go | 61 +++++-- lib/cache/workload_identity_test.go | 159 +++++++++++++++++- lib/services/local/workload_identity.go | 9 +- lib/services/local/workload_identity_test.go | 32 +++- lib/services/workload_identity.go | 15 +- lib/web/machineid_test.go | 56 ++++++ lib/web/workload_identity.go | 9 + lib/web/workload_identity_test.go | 51 ++++++ 14 files changed, 452 insertions(+), 78 deletions(-) diff --git a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go index 09b425968f2fc..8366d56c0faeb 100644 --- a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go +++ b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go @@ -21,6 +21,7 @@ package workloadidentityv1 import ( + types "github.com/gravitational/teleport/api/types" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" emptypb "google.golang.org/protobuf/types/known/emptypb" @@ -273,7 +274,9 @@ type ListWorkloadIdentitiesRequest struct { // The server may impose a different page size at its discretion. PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` // The page_token value returned from a previous ListWorkloadIdentities request, if any. - PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + // The sort config to use for the results. If empty, the default sort field and order is used. + Sort *types.SortBy `protobuf:"bytes,3,opt,name=sort,proto3" json:"sort,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -322,6 +325,13 @@ func (x *ListWorkloadIdentitiesRequest) GetPageToken() string { return "" } +func (x *ListWorkloadIdentitiesRequest) GetSort() *types.SortBy { + if x != nil { + return x.Sort + } + return nil +} + // The response for ListWorkloadIdentities. type ListWorkloadIdentitiesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -382,7 +392,7 @@ var File_teleport_workloadidentity_v1_resource_service_proto protoreflect.FileDe const file_teleport_workloadidentity_v1_resource_service_proto_rawDesc = "" + "\n" + - "3teleport/workloadidentity/v1/resource_service.proto\x12\x1cteleport.workloadidentity.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a+teleport/workloadidentity/v1/resource.proto\"|\n" + + "3teleport/workloadidentity/v1/resource_service.proto\x12\x1cteleport.workloadidentity.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a!teleport/legacy/types/types.proto\x1a+teleport/workloadidentity/v1/resource.proto\"|\n" + "\x1dCreateWorkloadIdentityRequest\x12[\n" + "\x11workload_identity\x18\x01 \x01(\v2..teleport.workloadidentity.v1.WorkloadIdentityR\x10workloadIdentity\"|\n" + "\x1dUpdateWorkloadIdentityRequest\x12[\n" + @@ -392,11 +402,12 @@ const file_teleport_workloadidentity_v1_resource_service_proto_rawDesc = "" + "\x1aGetWorkloadIdentityRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"3\n" + "\x1dDeleteWorkloadIdentityRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"[\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"~\n" + "\x1dListWorkloadIdentitiesRequest\x12\x1b\n" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + - "page_token\x18\x02 \x01(\tR\tpageToken\"\xa9\x01\n" + + "page_token\x18\x02 \x01(\tR\tpageToken\x12!\n" + + "\x04sort\x18\x03 \x01(\v2\r.types.SortByR\x04sort\"\xa9\x01\n" + "\x1eListWorkloadIdentitiesResponse\x12_\n" + "\x13workload_identities\x18\x01 \x03(\v2..teleport.workloadidentity.v1.WorkloadIdentityR\x12workloadIdentities\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken2\xbf\x06\n" + @@ -430,30 +441,32 @@ var file_teleport_workloadidentity_v1_resource_service_proto_goTypes = []any{ (*ListWorkloadIdentitiesRequest)(nil), // 5: teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest (*ListWorkloadIdentitiesResponse)(nil), // 6: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse (*WorkloadIdentity)(nil), // 7: teleport.workloadidentity.v1.WorkloadIdentity - (*emptypb.Empty)(nil), // 8: google.protobuf.Empty + (*types.SortBy)(nil), // 8: types.SortBy + (*emptypb.Empty)(nil), // 9: google.protobuf.Empty } var file_teleport_workloadidentity_v1_resource_service_proto_depIdxs = []int32{ 7, // 0: teleport.workloadidentity.v1.CreateWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity 7, // 1: teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity 7, // 2: teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity - 7, // 3: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse.workload_identities:type_name -> teleport.workloadidentity.v1.WorkloadIdentity - 0, // 4: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.CreateWorkloadIdentityRequest - 1, // 5: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest - 2, // 6: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest - 3, // 7: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:input_type -> teleport.workloadidentity.v1.GetWorkloadIdentityRequest - 4, // 8: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:input_type -> teleport.workloadidentity.v1.DeleteWorkloadIdentityRequest - 5, // 9: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:input_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest - 7, // 10: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 7, // 11: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 7, // 12: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 7, // 13: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 8, // 14: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:output_type -> google.protobuf.Empty - 6, // 15: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:output_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse - 10, // [10:16] is the sub-list for method output_type - 4, // [4:10] is the sub-list for method input_type - 4, // [4:4] is the sub-list for extension type_name - 4, // [4:4] is the sub-list for extension extendee - 0, // [0:4] is the sub-list for field type_name + 8, // 3: teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest.sort:type_name -> types.SortBy + 7, // 4: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse.workload_identities:type_name -> teleport.workloadidentity.v1.WorkloadIdentity + 0, // 5: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.CreateWorkloadIdentityRequest + 1, // 6: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest + 2, // 7: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest + 3, // 8: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:input_type -> teleport.workloadidentity.v1.GetWorkloadIdentityRequest + 4, // 9: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:input_type -> teleport.workloadidentity.v1.DeleteWorkloadIdentityRequest + 5, // 10: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:input_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest + 7, // 11: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 7, // 12: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 7, // 13: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 7, // 14: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 9, // 15: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:output_type -> google.protobuf.Empty + 6, // 16: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:output_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse + 11, // [11:17] is the sub-list for method output_type + 5, // [5:11] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_teleport_workloadidentity_v1_resource_service_proto_init() } diff --git a/api/proto/teleport/workloadidentity/v1/resource_service.proto b/api/proto/teleport/workloadidentity/v1/resource_service.proto index 71d41996ec745..1fd18e4fb8787 100644 --- a/api/proto/teleport/workloadidentity/v1/resource_service.proto +++ b/api/proto/teleport/workloadidentity/v1/resource_service.proto @@ -17,6 +17,7 @@ syntax = "proto3"; package teleport.workloadidentity.v1; import "google/protobuf/empty.proto"; +import "teleport/legacy/types/types.proto"; import "teleport/workloadidentity/v1/resource.proto"; option go_package = "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1;workloadidentityv1"; @@ -83,6 +84,8 @@ message ListWorkloadIdentitiesRequest { int32 page_size = 1; // The page_token value returned from a previous ListWorkloadIdentities request, if any. string page_token = 2; + // The sort config to use for the results. If empty, the default sort field and order is used. + types.SortBy sort = 3; } // The response for ListWorkloadIdentities. diff --git a/lib/auth/authclient/api.go b/lib/auth/authclient/api.go index 3e4d442e0e25c..6204ffdf10e2c 100644 --- a/lib/auth/authclient/api.go +++ b/lib/auth/authclient/api.go @@ -1295,7 +1295,7 @@ type Cache interface { GetWorkloadIdentity(ctx context.Context, name string) (*workloadidentityv1pb.WorkloadIdentity, error) // ListWorkloadIdentities lists all SPIFFE Federations using Google style // pagination. - ListWorkloadIdentities(ctx context.Context, pageSize int, lastToken string) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) + ListWorkloadIdentities(ctx context.Context, pageSize int, lastToken string, options *services.ListWorkloadIdentitiesRequestOptions) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) // ListStaticHostUsers lists static host users. ListStaticHostUsers(ctx context.Context, pageSize int, startKey string) ([]*userprovisioningpb.StaticHostUser, string, error) diff --git a/lib/auth/machineid/workloadidentityv1/issuer_service.go b/lib/auth/machineid/workloadidentityv1/issuer_service.go index 35928d5a7dc6b..d03e83e1e30c9 100644 --- a/lib/auth/machineid/workloadidentityv1/issuer_service.go +++ b/lib/auth/machineid/workloadidentityv1/issuer_service.go @@ -872,7 +872,7 @@ func (s *IssuanceService) getAllWorkloadIdentities( workloadIdentities := []*workloadidentityv1pb.WorkloadIdentity{} page := "" for { - pageItems, nextPage, err := s.cache.ListWorkloadIdentities(ctx, 0, page) + pageItems, nextPage, err := s.cache.ListWorkloadIdentities(ctx, 0, page, nil) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/auth/machineid/workloadidentityv1/resource_service.go b/lib/auth/machineid/workloadidentityv1/resource_service.go index 6ca893e1b1fa9..cc487cb7f6daf 100644 --- a/lib/auth/machineid/workloadidentityv1/resource_service.go +++ b/lib/auth/machineid/workloadidentityv1/resource_service.go @@ -31,11 +31,12 @@ import ( apievents "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/lib/authz" "github.com/gravitational/teleport/lib/events" + "github.com/gravitational/teleport/lib/services" ) type workloadIdentityReader interface { GetWorkloadIdentity(ctx context.Context, name string) (*workloadidentityv1pb.WorkloadIdentity, error) - ListWorkloadIdentities(ctx context.Context, pageSize int, token string) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) + ListWorkloadIdentities(ctx context.Context, pageSize int, token string, options *services.ListWorkloadIdentitiesRequestOptions) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) } type workloadIdentityReadWriter interface { @@ -142,6 +143,9 @@ func (s *ResourceService) ListWorkloadIdentities( ctx, int(req.PageSize), req.PageToken, + &services.ListWorkloadIdentitiesRequestOptions{ + Sort: req.Sort, + }, ) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/cache/bot_instance.go b/lib/cache/bot_instance.go index b55d4905d7b4f..5d37f7c934311 100644 --- a/lib/cache/bot_instance.go +++ b/lib/cache/bot_instance.go @@ -40,33 +40,6 @@ const ( botInstanceActiveAtIndex botInstanceIndex = "active_at_latest" ) -func keyForNameIndex(botInstance *machineidv1.BotInstance) string { - return makeNameIndexKey( - botInstance.GetSpec().GetBotName(), - botInstance.GetMetadata().GetName(), - ) -} - -func makeNameIndexKey(botName string, instanceID string) string { - return botName + "/" + instanceID -} - -func keyForActiveAtIndex(botInstance *machineidv1.BotInstance) string { - var recordedAt time.Time - - initialHeartbeatTime := botInstance.GetStatus().GetInitialHeartbeat().GetRecordedAt() - if initialHeartbeatTime != nil { - recordedAt = initialHeartbeatTime.AsTime() - } - - latestHeartbeats := botInstance.GetStatus().GetLatestHeartbeats() - if len(latestHeartbeats) > 0 { - recordedAt = latestHeartbeats[len(latestHeartbeats)-1].GetRecordedAt().AsTime() - } - - return recordedAt.Format(time.RFC3339) + "/" + botInstance.GetMetadata().GetName() -} - func newBotInstanceCollection(upstream services.BotInstance, w types.WatchKind) (*collection[*machineidv1.BotInstance, botInstanceIndex], error) { if upstream == nil { return nil, trace.BadParameter("missing parameter upstream (BotInstance)") @@ -78,9 +51,9 @@ func newBotInstanceCollection(upstream services.BotInstance, w types.WatchKind) proto.CloneOf[*machineidv1.BotInstance], map[botInstanceIndex]func(*machineidv1.BotInstance) string{ // Index on a combination of bot name and instance name - botInstanceNameIndex: keyForNameIndex, + botInstanceNameIndex: keyForBotInstanceNameIndex, // Index on a combination of most recent heartbeat time and instance name - botInstanceActiveAtIndex: keyForActiveAtIndex, + botInstanceActiveAtIndex: keyForBotInstanceActiveAtIndex, }), fetcher: func(ctx context.Context, loadSecrets bool) ([]*machineidv1.BotInstance, error) { out, err := stream.Collect(clientutils.Resources(ctx, @@ -108,7 +81,7 @@ func (c *Cache) GetBotInstance(ctx context.Context, botName, instanceID string) }, } - out, err := getter.get(ctx, makeNameIndexKey(botName, instanceID)) + out, err := getter.get(ctx, makeBotInstanceNameIndexKey(botName, instanceID)) return out, trace.Wrap(err) } @@ -118,7 +91,7 @@ func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize i defer span.End() index := botInstanceNameIndex - keyFn := keyForNameIndex + keyFn := keyForBotInstanceNameIndex var isDesc bool if sort != nil { isDesc = sort.IsDesc @@ -126,10 +99,10 @@ func (c *Cache) ListBotInstances(ctx context.Context, botName string, pageSize i switch sort.Field { case "bot_name": index = botInstanceNameIndex - keyFn = keyForNameIndex + keyFn = keyForBotInstanceNameIndex case "active_at_latest": index = botInstanceActiveAtIndex - keyFn = keyForActiveAtIndex + keyFn = keyForBotInstanceActiveAtIndex default: return nil, "", trace.BadParameter("unsupported sort %q but expected bot_name or active_at_latest", sort.Field) } @@ -188,3 +161,30 @@ func matchBotInstance(b *machineidv1.BotInstance, botName string, search string) return strings.Contains(strings.ToLower(val), strings.ToLower(search)) }) } + +func keyForBotInstanceNameIndex(botInstance *machineidv1.BotInstance) string { + return makeBotInstanceNameIndexKey( + botInstance.GetSpec().GetBotName(), + botInstance.GetMetadata().GetName(), + ) +} + +func makeBotInstanceNameIndexKey(botName string, instanceID string) string { + return botName + "/" + instanceID +} + +func keyForBotInstanceActiveAtIndex(botInstance *machineidv1.BotInstance) string { + var recordedAt time.Time + + initialHeartbeatTime := botInstance.GetStatus().GetInitialHeartbeat().GetRecordedAt() + if initialHeartbeatTime != nil { + recordedAt = initialHeartbeatTime.AsTime() + } + + latestHeartbeats := botInstance.GetStatus().GetLatestHeartbeats() + if len(latestHeartbeats) > 0 { + recordedAt = latestHeartbeats[len(latestHeartbeats)-1].GetRecordedAt().AsTime() + } + + return recordedAt.Format(time.RFC3339) + "/" + botInstance.GetMetadata().GetName() +} diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index 0fe4748d7879a..ce6371ddf5443 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -33,7 +33,10 @@ import ( type workloadIdentityIndex string -const workloadIdentityNameIndex workloadIdentityIndex = "name" +const ( + workloadIdentityNameIndex workloadIdentityIndex = "name" + workloadIdentitySpiffeIDIndex workloadIdentityIndex = "spiffe_id" +) func newWorkloadIdentityCollection(upstream services.WorkloadIdentities, w types.WatchKind) (*collection[*workloadidentityv1pb.WorkloadIdentity, workloadIdentityIndex], error) { if upstream == nil { @@ -45,12 +48,14 @@ func newWorkloadIdentityCollection(upstream services.WorkloadIdentities, w types types.KindWorkloadIdentity, proto.CloneOf[*workloadidentityv1pb.WorkloadIdentity], map[workloadIdentityIndex]func(*workloadidentityv1pb.WorkloadIdentity) string{ - workloadIdentityNameIndex: func(r *workloadidentityv1pb.WorkloadIdentity) string { - return r.GetMetadata().GetName() - }, + workloadIdentityNameIndex: keyForWorkloadIdentityNameIndex, + workloadIdentitySpiffeIDIndex: keyForWorkloadIdentitySpiffeIDIndex, }), fetcher: func(ctx context.Context, loadSecrets bool) ([]*workloadidentityv1pb.WorkloadIdentity, error) { - out, err := stream.Collect(clientutils.Resources(ctx, upstream.ListWorkloadIdentities)) + out, err := stream.Collect(clientutils.Resources(ctx, + func(ctx context.Context, pageSize int, currentToken string) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { + return upstream.ListWorkloadIdentities(ctx, pageSize, currentToken, nil) + })) return out, trace.Wrap(err) }, headerTransform: func(hdr *types.ResourceHeader) *workloadidentityv1pb.WorkloadIdentity { @@ -67,17 +72,43 @@ func newWorkloadIdentityCollection(upstream services.WorkloadIdentities, w types } // ListWorkloadIdentities returns a paginated list of WorkloadIdentity resources. -func (c *Cache) ListWorkloadIdentities(ctx context.Context, pageSize int, nextToken string) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { +func (c *Cache) ListWorkloadIdentities( + ctx context.Context, + pageSize int, + nextToken string, + options *services.ListWorkloadIdentitiesRequestOptions, +) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { ctx, span := c.Tracer.Start(ctx, "cache/ListWorkloadIdentities") defer span.End() + index := workloadIdentityNameIndex + keyFn := keyForWorkloadIdentityNameIndex + var isDesc bool + if options.HasSort() { + isDesc = options.Sort.IsDesc + + switch options.Sort.Field { + case "name": + index = workloadIdentityNameIndex + keyFn = keyForWorkloadIdentityNameIndex + case "spiffe_id": + index = workloadIdentitySpiffeIDIndex + keyFn = keyForWorkloadIdentitySpiffeIDIndex + default: + return nil, "", trace.BadParameter("unsupported sort %q but expected name or spiffe_id", options.Sort.Field) + } + } + lister := genericLister[*workloadidentityv1pb.WorkloadIdentity, workloadIdentityIndex]{ - cache: c, - collection: c.collections.workloadIdentity, - index: workloadIdentityNameIndex, - upstreamList: c.Config.WorkloadIdentity.ListWorkloadIdentities, + cache: c, + collection: c.collections.workloadIdentity, + index: index, + isDesc: isDesc, + upstreamList: func(ctx context.Context, pageSize int, nextToken string) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { + return c.Config.WorkloadIdentity.ListWorkloadIdentities(ctx, pageSize, nextToken, options) + }, nextToken: func(t *workloadidentityv1pb.WorkloadIdentity) string { - return t.GetMetadata().GetName() + return keyFn(t) }, } out, next, err := lister.list(ctx, pageSize, nextToken) @@ -98,3 +129,11 @@ func (c *Cache) GetWorkloadIdentity(ctx context.Context, name string) (*workload out, err := getter.get(ctx, name) return out, trace.Wrap(err) } + +func keyForWorkloadIdentityNameIndex(r *workloadidentityv1pb.WorkloadIdentity) string { + return r.GetMetadata().GetName() +} + +func keyForWorkloadIdentitySpiffeIDIndex(r *workloadidentityv1pb.WorkloadIdentity) string { + return r.GetSpec().GetSpiffe().GetId() +} diff --git a/lib/cache/workload_identity_test.go b/lib/cache/workload_identity_test.go index da82d64fec27c..738a9321dc3aa 100644 --- a/lib/cache/workload_identity_test.go +++ b/lib/cache/workload_identity_test.go @@ -19,12 +19,16 @@ package cache import ( "context" "testing" + "time" "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1" workloadidentityv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/services" ) func newWorkloadIdentity(name string) *workloadidentityv1pb.WorkloadIdentity { @@ -58,7 +62,7 @@ func TestWorkloadIdentity(t *testing.T) { return trace.Wrap(err) }, list: func(ctx context.Context) ([]*workloadidentityv1pb.WorkloadIdentity, error) { - items, _, err := p.workloadIdentity.ListWorkloadIdentities(ctx, 0, "") + items, _, err := p.workloadIdentity.ListWorkloadIdentities(ctx, 0, "", nil) return items, trace.Wrap(err) }, deleteAll: func(ctx context.Context) error { @@ -66,9 +70,160 @@ func TestWorkloadIdentity(t *testing.T) { }, cacheList: func(ctx context.Context) ([]*workloadidentityv1pb.WorkloadIdentity, error) { - items, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "") + items, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", nil) return items, trace.Wrap(err) }, cacheGet: p.cache.GetWorkloadIdentity, }) } + +// TestWorkloadIdentityCacheSorting tests that cache items are sorted. +func TestWorkloadIdentityCacheSorting(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + p := newTestPack(t, ForAuth) + t.Cleanup(p.Close) + + rs := []struct { + name string + spiffeId string + }{ + {"test-workload-identity-1", "/test/spiffe/2"}, + {"test-workload-identity-3", "/test/spiffe/1"}, + {"test-workload-identity-2", "/test/spiffe/3"}, + } + + for _, r := range rs { + id := &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: r.name, + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: r.spiffeId, + }, + }, + } + + _, err := p.workloadIdentity.CreateWorkloadIdentity(ctx, id) + require.NoError(t, err) + } + + // Let the cache catch up + require.EventuallyWithT(t, func(t *assert.CollectT) { + results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", nil) + require.NoError(t, err) + require.Len(t, results, 3) + }, 10*time.Second, 100*time.Millisecond) + + t.Run("sort ascending by spiffe_id", func(t *testing.T) { + results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + Sort: &types.SortBy{ + Field: "spiffe_id", + IsDesc: false, + }, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "test-workload-identity-3", results[0].GetMetadata().GetName()) + require.Equal(t, "test-workload-identity-1", results[1].GetMetadata().GetName()) + require.Equal(t, "test-workload-identity-2", results[2].GetMetadata().GetName()) + }) + + t.Run("sort descending by spiffe_id", func(t *testing.T) { + results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + Sort: &types.SortBy{ + Field: "spiffe_id", + IsDesc: true, + }, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "test-workload-identity-2", results[0].GetMetadata().GetName()) + require.Equal(t, "test-workload-identity-1", results[1].GetMetadata().GetName()) + require.Equal(t, "test-workload-identity-3", results[2].GetMetadata().GetName()) + }) + + t.Run("sort ascending by name", func(t *testing.T) { + results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", nil) // empty sort should default to `name:asc` + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "test-workload-identity-1", results[0].GetMetadata().GetName()) + require.Equal(t, "test-workload-identity-2", results[1].GetMetadata().GetName()) + require.Equal(t, "test-workload-identity-3", results[2].GetMetadata().GetName()) + }) + + t.Run("sort descending by name", func(t *testing.T) { + results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + Sort: &types.SortBy{ + Field: "name", + IsDesc: true, + }, + }) + require.NoError(t, err) + require.Len(t, results, 3) + require.Equal(t, "test-workload-identity-3", results[0].GetMetadata().GetName()) + require.Equal(t, "test-workload-identity-2", results[1].GetMetadata().GetName()) + require.Equal(t, "test-workload-identity-1", results[2].GetMetadata().GetName()) + }) +} + +// TestWorkloadIdentityCacheFallback tests that requests fallback to the upstream when the cache is unhealthy. +func TestWorkloadIdentityCacheFallback(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + p := newTestPack(t, func(cfg Config) Config { + cfg.neverOK = true // Force the cache into an unhealthy state + return ForAuth(cfg) + }) + t.Cleanup(p.Close) + + _, err := p.workloadIdentity.CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "test-workload-identity-1", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/test/spiffe/1", + }, + }, + }) + require.NoError(t, err) + + // Let the cache catch up + require.EventuallyWithT(t, func(t *assert.CollectT) { + results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", nil) + require.NoError(t, err) + require.Len(t, results, 1) + }, 10*time.Second, 100*time.Millisecond) + + t.Run("supported sort", func(t *testing.T) { + results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + Sort: &types.SortBy{ + Field: "name", + IsDesc: false, + }, + }) + require.NoError(t, err) // asc by name is the only sort supported by the upstream + require.Len(t, results, 1) + }) + + t.Run("unsupported sort", func(t *testing.T) { + _, _, err = p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + Sort: &types.SortBy{ + Field: "name", + IsDesc: true, + }, + }) + require.Error(t, err) + require.Equal(t, "unsupported sort, only name:asc is supported, but got \"name\" (desc = true)", err.Error()) + }) +} diff --git a/lib/services/local/workload_identity.go b/lib/services/local/workload_identity.go index b2bc9df620cce..c76bb6e95676e 100644 --- a/lib/services/local/workload_identity.go +++ b/lib/services/local/workload_identity.go @@ -78,8 +78,15 @@ func (b *WorkloadIdentityService) GetWorkloadIdentity( // ListWorkloadIdentities lists all WorkloadIdentities using a given page size // and last key. func (b *WorkloadIdentityService) ListWorkloadIdentities( - ctx context.Context, pageSize int, currentToken string, + ctx context.Context, + pageSize int, + currentToken string, + options *services.ListWorkloadIdentitiesRequestOptions, ) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { + if options.HasSort() && (options.Sort.Field != "name" || options.Sort.IsDesc != false) { + return nil, "", trace.BadParameter("unsupported sort, only name:asc is supported, but got %q (desc = %t)", options.Sort.Field, options.Sort.IsDesc) + } + r, nextToken, err := b.service.ListResources(ctx, pageSize, currentToken) return r, nextToken, trace.Wrap(err) } diff --git a/lib/services/local/workload_identity_test.go b/lib/services/local/workload_identity_test.go index 31cc246fd1fd6..046e99df9a129 100644 --- a/lib/services/local/workload_identity_test.go +++ b/lib/services/local/workload_identity_test.go @@ -25,6 +25,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/gravitational/trace" "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" @@ -34,6 +35,7 @@ import ( "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/backend/memory" + "github.com/gravitational/teleport/lib/services" ) func setupWorkloadIdentityServiceTest( @@ -157,7 +159,7 @@ func TestWorkloadIdentityService_ListWorkloadIdentities(t *testing.T) { createdObjects = append(createdObjects, created) } t.Run("default page size", func(t *testing.T) { - page, nextToken, err := service.ListWorkloadIdentities(ctx, 0, "") + page, nextToken, err := service.ListWorkloadIdentities(ctx, 0, "", nil) require.NoError(t, err) require.Len(t, page, 49) require.Empty(t, nextToken) @@ -175,7 +177,7 @@ func TestWorkloadIdentityService_ListWorkloadIdentities(t *testing.T) { iterations := 0 for { iterations++ - page, nextToken, err := service.ListWorkloadIdentities(ctx, 10, token) + page, nextToken, err := service.ListWorkloadIdentities(ctx, 10, token, nil) require.NoError(t, err) fetched = append(fetched, page...) if nextToken == "" { @@ -193,6 +195,28 @@ func TestWorkloadIdentityService_ListWorkloadIdentities(t *testing.T) { })) } }) + t.Run("default sort", func(t *testing.T) { + page, nextToken, err := service.ListWorkloadIdentities(ctx, 0, "", nil) + require.NoError(t, err) + require.Len(t, page, 49) + require.Empty(t, nextToken) + + prevName := "" + for i := range len(page) { + assert.Greater(t, page[i].GetMetadata().GetName(), prevName) + prevName = page[i].GetMetadata().GetName() + } + }) + t.Run("unsupported sort error", func(t *testing.T) { + _, _, err := service.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + Sort: &types.SortBy{ + Field: "name", + IsDesc: true, + }, + }) + require.Error(t, err) + require.Equal(t, `unsupported sort, only name:asc is supported, but got "name" (desc = true)`, err.Error()) + }) } func TestWorkloadIdentityService_GetWorkloadIdentity(t *testing.T) { @@ -263,14 +287,14 @@ func TestWorkloadIdentityService_DeleteAllWorkloadIdentities(t *testing.T) { ) require.NoError(t, err) - page, _, err := service.ListWorkloadIdentities(ctx, 0, "") + page, _, err := service.ListWorkloadIdentities(ctx, 0, "", nil) require.NoError(t, err) require.Len(t, page, 2) err = service.DeleteAllWorkloadIdentities(ctx) require.NoError(t, err) - page, _, err = service.ListWorkloadIdentities(ctx, 0, "") + page, _, err = service.ListWorkloadIdentities(ctx, 0, "", nil) require.NoError(t, err) require.Empty(t, page) } diff --git a/lib/services/workload_identity.go b/lib/services/workload_identity.go index 76277dfdec944..9837c78f81002 100644 --- a/lib/services/workload_identity.go +++ b/lib/services/workload_identity.go @@ -39,7 +39,10 @@ type WorkloadIdentities interface { // ListWorkloadIdentities lists all WorkloadIdentities using Google style // pagination. ListWorkloadIdentities( - ctx context.Context, pageSize int, lastToken string, + ctx context.Context, + pageSize int, + lastToken string, + options *ListWorkloadIdentitiesRequestOptions, ) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) // CreateWorkloadIdentity creates a new WorkloadIdentity. CreateWorkloadIdentity( @@ -133,3 +136,13 @@ func ValidateWorkloadIdentity(s *workloadidentityv1pb.WorkloadIdentity) error { return nil } + +type ListWorkloadIdentitiesRequestOptions struct { + // The sort config to use for the results. If empty, the default sort field + // and order is used. + Sort *types.SortBy +} + +func (o *ListWorkloadIdentitiesRequestOptions) HasSort() bool { + return o != nil && o.Sort != nil +} diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index 451c0ad851318..197d88cb6615d 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -755,6 +755,62 @@ func TestListBotInstancesPaging(t *testing.T) { } } +func TestListBotInstancesSorting(t *testing.T) { + t.Parallel() + + ctx := context.Background() + env := newWebPackWithOptions(t, &WebPackOptions{ + enableAuthCache: true, + }) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoint := pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot-instance", + ) + + for i := range 10 { + now := time.Now() + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: "bot-1", + InstanceId: uuid.New().String(), + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + RecordedAt: ×tamppb.Timestamp{ + Seconds: now.Unix() + int64(i), + }, + }, + }, + }) + require.NoError(t, err) + } + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "page_token": []string{""}, // default to the start + "page_size": []string{"0"}, + "sort": []string{"active_at_latest:desc"}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var resp ListBotInstancesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") + + prevValue := "~" + for _, r := range resp.BotInstances { + assert.Less(t, r.ActiveAtLatest, prevValue) + prevValue = r.ActiveAtLatest + } +} + func TestListBotInstancesWithBotFilter(t *testing.T) { t.Parallel() diff --git a/lib/web/workload_identity.go b/lib/web/workload_identity.go index 061ce59937e6c..8193f6c629d26 100644 --- a/lib/web/workload_identity.go +++ b/lib/web/workload_identity.go @@ -26,6 +26,7 @@ import ( "github.com/julienschmidt/httprouter" workloadidentityv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" + "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/reversetunnelclient" tslices "github.com/gravitational/teleport/lib/utils/slices" ) @@ -46,9 +47,17 @@ func (h *Handler) listWorkloadIdentities(_ http.ResponseWriter, r *http.Request, } } + var sort *types.SortBy + if r.URL.Query().Has("sort") { + sortString := r.URL.Query().Get("sort") + s := types.GetSortByFromString(sortString) + sort = &s + } + result, err := clt.WorkloadIdentityResourceServiceClient().ListWorkloadIdentities(r.Context(), &workloadidentityv1.ListWorkloadIdentitiesRequest{ PageSize: int32(pageSize), PageToken: r.URL.Query().Get("page_token"), + Sort: sort, }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/web/workload_identity_test.go b/lib/web/workload_identity_test.go index 0ff12803d780a..77707bc058473 100644 --- a/lib/web/workload_identity_test.go +++ b/lib/web/workload_identity_test.go @@ -166,3 +166,54 @@ func TestListWorkloadIdentitiesPaging(t *testing.T) { }) } } + +func TestListWorkloadIdentitiesSorting(t *testing.T) { + t.Parallel() + + ctx := context.Background() + env := newWebPackWithOptions(t, &WebPackOptions{ + enableAuthCache: true, + }) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoint := pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "workload-identity", + ) + + for range 10 { + _, err := env.server.Auth().CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: uuid.New().String(), + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/test/spiffe/" + uuid.New().String(), + }, + }, + }) + require.NoError(t, err) + } + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "page_token": []string{""}, // default to the start + "page_size": []string{"0"}, + "sort": []string{"spiffe_id:desc"}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var resp ListWorkloadIdentitiesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") + + prevValue := "~" + for _, r := range resp.Items { + assert.Less(t, r.SpiffeID, prevValue) + prevValue = r.SpiffeID + } +} From 07f4139a5048fff2fe642069a510e9459967d8b4 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 8 Sep 2025 18:35:10 +0100 Subject: [PATCH 05/33] Add filter by search term --- .../v1/resource_service.pb.go | 20 +++-- .../v1/resource_service.proto | 2 + .../workloadidentityv1/resource_service.go | 3 +- lib/cache/workload_identity.go | 5 +- lib/cache/workload_identity_test.go | 40 +++++++++ lib/services/local/workload_identity.go | 16 +++- lib/services/local/workload_identity_test.go | 24 +++++- lib/services/workload_identity.go | 36 +++++++- lib/web/workload_identity.go | 7 +- lib/web/workload_identity_test.go | 86 +++++++++++++++++++ 10 files changed, 224 insertions(+), 15 deletions(-) diff --git a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go index 8366d56c0faeb..6fd55be1f5556 100644 --- a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go +++ b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go @@ -276,9 +276,11 @@ type ListWorkloadIdentitiesRequest struct { // The page_token value returned from a previous ListWorkloadIdentities request, if any. PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` // The sort config to use for the results. If empty, the default sort field and order is used. - Sort *types.SortBy `protobuf:"bytes,3,opt,name=sort,proto3" json:"sort,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Sort *types.SortBy `protobuf:"bytes,3,opt,name=sort,proto3" json:"sort,omitempty"` + // A search term used to filter the results. If non-empty, it's used to match against supported fields. + FilterSearchTerm string `protobuf:"bytes,4,opt,name=filter_search_term,json=filterSearchTerm,proto3" json:"filter_search_term,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListWorkloadIdentitiesRequest) Reset() { @@ -332,6 +334,13 @@ func (x *ListWorkloadIdentitiesRequest) GetSort() *types.SortBy { return nil } +func (x *ListWorkloadIdentitiesRequest) GetFilterSearchTerm() string { + if x != nil { + return x.FilterSearchTerm + } + return "" +} + // The response for ListWorkloadIdentities. type ListWorkloadIdentitiesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -402,12 +411,13 @@ const file_teleport_workloadidentity_v1_resource_service_proto_rawDesc = "" + "\x1aGetWorkloadIdentityRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"3\n" + "\x1dDeleteWorkloadIdentityRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"~\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\xac\x01\n" + "\x1dListWorkloadIdentitiesRequest\x12\x1b\n" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + "page_token\x18\x02 \x01(\tR\tpageToken\x12!\n" + - "\x04sort\x18\x03 \x01(\v2\r.types.SortByR\x04sort\"\xa9\x01\n" + + "\x04sort\x18\x03 \x01(\v2\r.types.SortByR\x04sort\x12,\n" + + "\x12filter_search_term\x18\x04 \x01(\tR\x10filterSearchTerm\"\xa9\x01\n" + "\x1eListWorkloadIdentitiesResponse\x12_\n" + "\x13workload_identities\x18\x01 \x03(\v2..teleport.workloadidentity.v1.WorkloadIdentityR\x12workloadIdentities\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken2\xbf\x06\n" + diff --git a/api/proto/teleport/workloadidentity/v1/resource_service.proto b/api/proto/teleport/workloadidentity/v1/resource_service.proto index 1fd18e4fb8787..7312e3625d9e9 100644 --- a/api/proto/teleport/workloadidentity/v1/resource_service.proto +++ b/api/proto/teleport/workloadidentity/v1/resource_service.proto @@ -86,6 +86,8 @@ message ListWorkloadIdentitiesRequest { string page_token = 2; // The sort config to use for the results. If empty, the default sort field and order is used. types.SortBy sort = 3; + // A search term used to filter the results. If non-empty, it's used to match against supported fields. + string filter_search_term = 4; } // The response for ListWorkloadIdentities. diff --git a/lib/auth/machineid/workloadidentityv1/resource_service.go b/lib/auth/machineid/workloadidentityv1/resource_service.go index cc487cb7f6daf..8f01647735b8e 100644 --- a/lib/auth/machineid/workloadidentityv1/resource_service.go +++ b/lib/auth/machineid/workloadidentityv1/resource_service.go @@ -144,7 +144,8 @@ func (s *ResourceService) ListWorkloadIdentities( int(req.PageSize), req.PageToken, &services.ListWorkloadIdentitiesRequestOptions{ - Sort: req.Sort, + Sort: req.GetSort(), + FilterSearchTerm: req.GetFilterSearchTerm(), }, ) if err != nil { diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index ce6371ddf5443..52ca866e091cd 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -84,7 +84,7 @@ func (c *Cache) ListWorkloadIdentities( index := workloadIdentityNameIndex keyFn := keyForWorkloadIdentityNameIndex var isDesc bool - if options.HasSort() { + if options.GetSort() != nil { isDesc = options.Sort.IsDesc switch options.Sort.Field { @@ -107,6 +107,9 @@ func (c *Cache) ListWorkloadIdentities( upstreamList: func(ctx context.Context, pageSize int, nextToken string) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { return c.Config.WorkloadIdentity.ListWorkloadIdentities(ctx, pageSize, nextToken, options) }, + filter: func(b *workloadidentityv1pb.WorkloadIdentity) bool { + return services.MatchWorkloadIdentity(b, options.GetFilterSearchTerm()) + }, nextToken: func(t *workloadidentityv1pb.WorkloadIdentity) string { return keyFn(t) }, diff --git a/lib/cache/workload_identity_test.go b/lib/cache/workload_identity_test.go index 738a9321dc3aa..6ce78ec0f4095 100644 --- a/lib/cache/workload_identity_test.go +++ b/lib/cache/workload_identity_test.go @@ -18,6 +18,7 @@ package cache import ( "context" + "strconv" "testing" "time" @@ -227,3 +228,42 @@ func TestWorkloadIdentityCacheFallback(t *testing.T) { require.Equal(t, "unsupported sort, only name:asc is supported, but got \"name\" (desc = true)", err.Error()) }) } + +// TestWorkloadIdentityCacheSearchFilter tests that cache items are filtered by search query. +func TestWorkloadIdentityCacheSearchFilter(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + p := newTestPack(t, ForAuth) + t.Cleanup(p.Close) + + for n := range 10 { + _, err := p.workloadIdentity.CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "test-workload-identity-" + strconv.Itoa(n), + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/test/" + strconv.Itoa(n%2) + "/id" + strconv.Itoa(n), + }, + }, + }) + require.NoError(t, err) + } + + // Let the cache catch up + require.EventuallyWithT(t, func(t *assert.CollectT) { + results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", nil) + require.NoError(t, err) + require.Len(t, results, 10) + }, 10*time.Second, 100*time.Millisecond) + + results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + FilterSearchTerm: "test/1", + }) + require.NoError(t, err) + require.Len(t, results, 5) +} diff --git a/lib/services/local/workload_identity.go b/lib/services/local/workload_identity.go index c76bb6e95676e..b3659e7b2589f 100644 --- a/lib/services/local/workload_identity.go +++ b/lib/services/local/workload_identity.go @@ -83,11 +83,23 @@ func (b *WorkloadIdentityService) ListWorkloadIdentities( currentToken string, options *services.ListWorkloadIdentitiesRequestOptions, ) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { - if options.HasSort() && (options.Sort.Field != "name" || options.Sort.IsDesc != false) { + if options.GetSort() != nil && (options.GetSort().Field != "name" || options.GetSort().IsDesc != false) { return nil, "", trace.BadParameter("unsupported sort, only name:asc is supported, but got %q (desc = %t)", options.Sort.Field, options.Sort.IsDesc) } - r, nextToken, err := b.service.ListResources(ctx, pageSize, currentToken) + if options.GetFilterSearchTerm() == "" { + r, nextToken, err := b.service.ListResources(ctx, pageSize, currentToken) + return r, nextToken, trace.Wrap(err) + } + + r, nextToken, err := b.service.ListResourcesWithFilter( + ctx, + pageSize, + currentToken, + func(item *workloadidentityv1pb.WorkloadIdentity) bool { + return services.MatchWorkloadIdentity(item, options.GetFilterSearchTerm()) + }, + ) return r, nextToken, trace.Wrap(err) } diff --git a/lib/services/local/workload_identity_test.go b/lib/services/local/workload_identity_test.go index 046e99df9a129..8c215c86cfd19 100644 --- a/lib/services/local/workload_identity_test.go +++ b/lib/services/local/workload_identity_test.go @@ -63,7 +63,8 @@ func newValidWorkloadIdentity(name string) *workloadidentityv1pb.WorkloadIdentit }, Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ - Id: "/test", + Id: "/test/" + name, + Hint: "This is hint " + name, }, }, } @@ -217,6 +218,27 @@ func TestWorkloadIdentityService_ListWorkloadIdentities(t *testing.T) { require.Error(t, err) require.Equal(t, `unsupported sort, only name:asc is supported, but got "name" (desc = true)`, err.Error()) }) + t.Run("search filter match on name", func(t *testing.T) { + page, _, err := service.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + FilterSearchTerm: "9", + }) + require.NoError(t, err) + assert.Len(t, page, 4) + }) + t.Run("search filter match on spiffe id", func(t *testing.T) { + page, _, err := service.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + FilterSearchTerm: "test/22", + }) + require.NoError(t, err) + assert.Len(t, page, 1) + }) + t.Run("search filter match on spiffe hint", func(t *testing.T) { + page, _, err := service.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + FilterSearchTerm: "hint 13", + }) + require.NoError(t, err) + assert.Len(t, page, 1) + }) } func TestWorkloadIdentityService_GetWorkloadIdentity(t *testing.T) { diff --git a/lib/services/workload_identity.go b/lib/services/workload_identity.go index 9837c78f81002..27645bc969094 100644 --- a/lib/services/workload_identity.go +++ b/lib/services/workload_identity.go @@ -18,6 +18,7 @@ package services import ( "context" + "slices" "strings" "time" @@ -141,8 +142,39 @@ type ListWorkloadIdentitiesRequestOptions struct { // The sort config to use for the results. If empty, the default sort field // and order is used. Sort *types.SortBy + // A search term used to filter the results. If non-empty, it's used to match against supported fields. + FilterSearchTerm string } -func (o *ListWorkloadIdentitiesRequestOptions) HasSort() bool { - return o != nil && o.Sort != nil +func (o *ListWorkloadIdentitiesRequestOptions) GetSort() *types.SortBy { + if o == nil { + return nil + } + return o.Sort +} + +func (o *ListWorkloadIdentitiesRequestOptions) GetFilterSearchTerm() string { + if o == nil { + return "" + } + return o.FilterSearchTerm +} + +func MatchWorkloadIdentity(item *workloadidentityv1pb.WorkloadIdentity, filterSearchTerm string) bool { + if item == nil { + return false + } + if filterSearchTerm == "" { + return true + } + + values := []string{ + item.GetMetadata().GetName(), + item.GetSpec().GetSpiffe().GetId(), + item.GetSpec().GetSpiffe().GetHint(), + } + + return slices.ContainsFunc(values, func(val string) bool { + return strings.Contains(strings.ToLower(val), strings.ToLower(filterSearchTerm)) + }) } diff --git a/lib/web/workload_identity.go b/lib/web/workload_identity.go index 8193f6c629d26..2d557833d31b2 100644 --- a/lib/web/workload_identity.go +++ b/lib/web/workload_identity.go @@ -55,9 +55,10 @@ func (h *Handler) listWorkloadIdentities(_ http.ResponseWriter, r *http.Request, } result, err := clt.WorkloadIdentityResourceServiceClient().ListWorkloadIdentities(r.Context(), &workloadidentityv1.ListWorkloadIdentitiesRequest{ - PageSize: int32(pageSize), - PageToken: r.URL.Query().Get("page_token"), - Sort: sort, + PageSize: int32(pageSize), + PageToken: r.URL.Query().Get("page_token"), + Sort: sort, + FilterSearchTerm: r.URL.Query().Get("search"), }) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/web/workload_identity_test.go b/lib/web/workload_identity_test.go index 77707bc058473..f968e6050dc3a 100644 --- a/lib/web/workload_identity_test.go +++ b/lib/web/workload_identity_test.go @@ -217,3 +217,89 @@ func TestListWorkloadIdentitiesSorting(t *testing.T) { prevValue = r.SpiffeID } } + +func TestListWorkloadIdentitiesWithSearchTermFilter(t *testing.T) { + t.Parallel() + + tcs := []struct { + name string + searchTerm string + metadata *headerv1.Metadata + spec *workloadidentityv1pb.WorkloadIdentitySpec + }{ + { + name: "match on name", + searchTerm: "nick", + metadata: &headerv1.Metadata{ + Name: "this-is-nicks-workload-identity", + }, + spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/spiffe/id/99", + }, + }, + }, + { + name: "match on spiffe id", + searchTerm: "id/22", + metadata: &headerv1.Metadata{ + Name: "this-is-nicks-workload-identity", + }, + spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/spiffe/id/22", + }, + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + endpoint := pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "workload-identity", + ) + + _, err := env.server.Auth().CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: tc.metadata, + Spec: tc.spec, + }) + require.NoError(t, err) + + _, err = env.server.Auth().CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ + Kind: types.KindWorkloadIdentity, + Version: types.V1, + Metadata: &headerv1.Metadata{ + Name: "gone", + }, + Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ + Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ + Id: "/test/spiffe/id", + }, + }, + }) + require.NoError(t, err) + + response, err := pack.clt.Get(ctx, endpoint, url.Values{ + "search": []string{tc.searchTerm}, + }) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var resp ListWorkloadIdentitiesResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") + + assert.Len(t, resp.Items, 1) + assert.Equal(t, "this-is-nicks-workload-identity", resp.Items[0].Name) + }) + } +} From 8e7628d0ea3a80c661800227b5a8808dc77923da Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Tue, 9 Sep 2025 14:30:38 +0100 Subject: [PATCH 06/33] Use `require.ErrorContains` --- lib/cache/workload_identity_test.go | 3 +-- lib/services/local/workload_identity_test.go | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/cache/workload_identity_test.go b/lib/cache/workload_identity_test.go index 6ce78ec0f4095..cde29b5607728 100644 --- a/lib/cache/workload_identity_test.go +++ b/lib/cache/workload_identity_test.go @@ -224,8 +224,7 @@ func TestWorkloadIdentityCacheFallback(t *testing.T) { IsDesc: true, }, }) - require.Error(t, err) - require.Equal(t, "unsupported sort, only name:asc is supported, but got \"name\" (desc = true)", err.Error()) + require.ErrorContains(t, err, "unsupported sort, only name:asc is supported, but got \"name\" (desc = true)") }) } diff --git a/lib/services/local/workload_identity_test.go b/lib/services/local/workload_identity_test.go index 8c215c86cfd19..4970511a2b750 100644 --- a/lib/services/local/workload_identity_test.go +++ b/lib/services/local/workload_identity_test.go @@ -215,8 +215,7 @@ func TestWorkloadIdentityService_ListWorkloadIdentities(t *testing.T) { IsDesc: true, }, }) - require.Error(t, err) - require.Equal(t, `unsupported sort, only name:asc is supported, but got "name" (desc = true)`, err.Error()) + require.ErrorContains(t, err, `unsupported sort, only name:asc is supported, but got "name" (desc = true)`, err.Error()) }) t.Run("search filter match on name", func(t *testing.T) { page, _, err := service.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ From d217a5f3f966650f2e680b37b43b905440fed0c5 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Tue, 9 Sep 2025 16:10:50 +0100 Subject: [PATCH 07/33] Refactor `newWebPackWithOptions` --- lib/web/apiserver_test.go | 48 ++++++++++++++++++++----------- lib/web/machineid_test.go | 4 +-- lib/web/workload_identity_test.go | 4 +-- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index 5d89fe0804129..4bcd82badba97 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -8123,32 +8123,46 @@ func decodeSessionCookie(t *testing.T, value string) (sessionID string) { type WebPackOptions struct { // Number of proxies to setup (default: 1) numProxies int - opts []proxyOption + proxyOptions []proxyOption enableAuthCache bool } -func (o *WebPackOptions) setDefaultOptions() { - if o == nil { - return +type webPackOptions = func(*WebPackOptions) + +func withWebPackProxyOptions(opts ...proxyOption) webPackOptions { + return func(cfg *WebPackOptions) { + cfg.proxyOptions = opts + } +} + +func withWebPackNumProxies(numProxies int) webPackOptions { + return func(cfg *WebPackOptions) { + cfg.numProxies = numProxies } - if o.numProxies <= 0 { - o.numProxies = 1 +} + +func withWebPackAuthCacheEnabled(enable bool) webPackOptions { + return func(cfg *WebPackOptions) { + cfg.enableAuthCache = enable } } -// Deprecated: use newWebPackWithOptions instead -// -// TODO(nicholasmarais1158): Replace uses of this function, then rename `newWebPackWithOptions` to `newWebPack`. func newWebPack(t *testing.T, numProxies int, opts ...proxyOption) *webPack { - return newWebPackWithOptions(t, &WebPackOptions{ - numProxies: numProxies, - opts: opts, - enableAuthCache: false, - }) + return newWebPackWithOptions( + t, + withWebPackProxyOptions(opts...), + withWebPackNumProxies(numProxies), + ) } -func newWebPackWithOptions(t *testing.T, options *WebPackOptions) *webPack { - options.setDefaultOptions() +func newWebPackWithOptions(t *testing.T, opts ...webPackOptions) *webPack { + options := &WebPackOptions{ + numProxies: 1, + } + + for _, opt := range opts { + opt(options) + } ctx := context.Background() clock := clockwork.NewFakeClockAt(time.Now()) @@ -8272,7 +8286,7 @@ func newWebPackWithOptions(t *testing.T, options *WebPackOptions) *webPack { var proxies []*testProxy for p := range options.numProxies { proxyID := fmt.Sprintf("proxy%v", p) - proxies = append(proxies, createProxy(ctx, t, proxyID, node, server.TLS, hostSigners, clock, options.opts...)) + proxies = append(proxies, createProxy(ctx, t, proxyID, node, server.TLS, hostSigners, clock, options.proxyOptions...)) } // Wait for proxies to fully register before starting the test. diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index 197d88cb6615d..0151af738390b 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -759,9 +759,7 @@ func TestListBotInstancesSorting(t *testing.T) { t.Parallel() ctx := context.Background() - env := newWebPackWithOptions(t, &WebPackOptions{ - enableAuthCache: true, - }) + env := newWebPackWithOptions(t, withWebPackAuthCacheEnabled(true)) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() diff --git a/lib/web/workload_identity_test.go b/lib/web/workload_identity_test.go index f968e6050dc3a..5836d942cb0db 100644 --- a/lib/web/workload_identity_test.go +++ b/lib/web/workload_identity_test.go @@ -171,9 +171,7 @@ func TestListWorkloadIdentitiesSorting(t *testing.T) { t.Parallel() ctx := context.Background() - env := newWebPackWithOptions(t, &WebPackOptions{ - enableAuthCache: true, - }) + env := newWebPackWithOptions(t, withWebPackAuthCacheEnabled(true)) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() From c684522f4fbd270b16d4d98fc613bd3bdc20b91f Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Tue, 9 Sep 2025 17:53:32 +0100 Subject: [PATCH 08/33] Use `t.Context()` --- lib/cache/workload_identity_test.go | 6 +++--- lib/web/machineid_test.go | 2 +- lib/web/workload_identity_test.go | 9 ++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/lib/cache/workload_identity_test.go b/lib/cache/workload_identity_test.go index 7d02454751c63..e1b0e8236307f 100644 --- a/lib/cache/workload_identity_test.go +++ b/lib/cache/workload_identity_test.go @@ -82,7 +82,7 @@ func TestWorkloadIdentity(t *testing.T) { func TestWorkloadIdentityCacheSorting(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) @@ -177,7 +177,7 @@ func TestWorkloadIdentityCacheSorting(t *testing.T) { func TestWorkloadIdentityCacheFallback(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, func(cfg Config) Config { cfg.neverOK = true // Force the cache into an unhealthy state @@ -232,7 +232,7 @@ func TestWorkloadIdentityCacheFallback(t *testing.T) { func TestWorkloadIdentityCacheSearchFilter(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() p := newTestPack(t, ForAuth) t.Cleanup(p.Close) diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index 0151af738390b..e10a9c2144504 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -758,7 +758,7 @@ func TestListBotInstancesPaging(t *testing.T) { func TestListBotInstancesSorting(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPackWithOptions(t, withWebPackAuthCacheEnabled(true)) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) diff --git a/lib/web/workload_identity_test.go b/lib/web/workload_identity_test.go index 5836d942cb0db..30f8bd0f06670 100644 --- a/lib/web/workload_identity_test.go +++ b/lib/web/workload_identity_test.go @@ -19,7 +19,6 @@ package web import ( - "context" "encoding/json" "math" "net/http" @@ -41,7 +40,7 @@ import ( func TestListWorkloadIdentities(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -124,7 +123,7 @@ func TestListWorkloadIdentitiesPaging(t *testing.T) { for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -170,7 +169,7 @@ func TestListWorkloadIdentitiesPaging(t *testing.T) { func TestListWorkloadIdentitiesSorting(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() env := newWebPackWithOptions(t, withWebPackAuthCacheEnabled(true)) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) @@ -253,7 +252,7 @@ func TestListWorkloadIdentitiesWithSearchTermFilter(t *testing.T) { for _, tc := range tcs { t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() + ctx := t.Context() env := newWebPack(t, 1) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) From b75965e2b7a29361ec083d7445be9b2470b0aaa7 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Tue, 9 Sep 2025 18:10:51 +0100 Subject: [PATCH 09/33] Add context to uses of `require.NoError` in loops --- lib/cache/workload_identity_test.go | 7 ++++--- lib/web/machineid_test.go | 2 +- lib/web/workload_identity_test.go | 8 ++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/cache/workload_identity_test.go b/lib/cache/workload_identity_test.go index e1b0e8236307f..be70ee680314d 100644 --- a/lib/cache/workload_identity_test.go +++ b/lib/cache/workload_identity_test.go @@ -111,7 +111,7 @@ func TestWorkloadIdentityCacheSorting(t *testing.T) { } _, err := p.workloadIdentity.CreateWorkloadIdentity(ctx, id) - require.NoError(t, err) + require.NoError(t, err, "failed to create WorkloadIdentity %q", r.name) } // Let the cache catch up @@ -238,11 +238,12 @@ func TestWorkloadIdentityCacheSearchFilter(t *testing.T) { t.Cleanup(p.Close) for n := range 10 { + name := "test-workload-identity-" + strconv.Itoa(n) _, err := p.workloadIdentity.CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ Kind: types.KindWorkloadIdentity, Version: types.V1, Metadata: &headerv1.Metadata{ - Name: "test-workload-identity-" + strconv.Itoa(n), + Name: name, }, Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ @@ -250,7 +251,7 @@ func TestWorkloadIdentityCacheSearchFilter(t *testing.T) { }, }, }) - require.NoError(t, err) + require.NoError(t, err, "failed to create WorkloadIdentity %q", name) } // Let the cache catch up diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index e10a9c2144504..dc0dbf2220d0d 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -788,7 +788,7 @@ func TestListBotInstancesSorting(t *testing.T) { }, }, }) - require.NoError(t, err) + require.NoError(t, err, "failed to create BotInstance index:%d", i) } response, err := pack.clt.Get(ctx, endpoint, url.Values{ diff --git a/lib/web/workload_identity_test.go b/lib/web/workload_identity_test.go index 30f8bd0f06670..92f7346f5d79d 100644 --- a/lib/web/workload_identity_test.go +++ b/lib/web/workload_identity_test.go @@ -135,7 +135,7 @@ func TestListWorkloadIdentitiesPaging(t *testing.T) { "workload-identity", ) - for range tc.numInstances { + for i := range tc.numInstances { _, err := env.server.Auth().CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ Kind: types.KindWorkloadIdentity, Version: types.V1, @@ -148,7 +148,7 @@ func TestListWorkloadIdentitiesPaging(t *testing.T) { }, }, }) - require.NoError(t, err) + require.NoError(t, err, "failed to create WorkloadIdentity index:%d", i) } response, err := pack.clt.Get(ctx, endpoint, url.Values{ @@ -181,7 +181,7 @@ func TestListWorkloadIdentitiesSorting(t *testing.T) { "workload-identity", ) - for range 10 { + for i := range 10 { _, err := env.server.Auth().CreateWorkloadIdentity(ctx, &workloadidentityv1pb.WorkloadIdentity{ Kind: types.KindWorkloadIdentity, Version: types.V1, @@ -194,7 +194,7 @@ func TestListWorkloadIdentitiesSorting(t *testing.T) { }, }, }) - require.NoError(t, err) + require.NoError(t, err, "failed to create WorkloadIdentity index:%d", i) } response, err := pack.clt.Get(ctx, endpoint, url.Values{ From 7b2f32032b8ed3c4107dd4ce196c5ef7ed52a154 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Tue, 9 Sep 2025 18:13:52 +0100 Subject: [PATCH 10/33] Tidy-up --- lib/cache/workload_identity_test.go | 4 ++-- lib/services/useracl.go | 2 +- lib/web/apiserver_test.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/cache/workload_identity_test.go b/lib/cache/workload_identity_test.go index be70ee680314d..d5b8e7b598814 100644 --- a/lib/cache/workload_identity_test.go +++ b/lib/cache/workload_identity_test.go @@ -89,7 +89,7 @@ func TestWorkloadIdentityCacheSorting(t *testing.T) { rs := []struct { name string - spiffeId string + spiffeID string }{ {"test-workload-identity-1", "/test/spiffe/2"}, {"test-workload-identity-3", "/test/spiffe/1"}, @@ -105,7 +105,7 @@ func TestWorkloadIdentityCacheSorting(t *testing.T) { }, Spec: &workloadidentityv1pb.WorkloadIdentitySpec{ Spiffe: &workloadidentityv1pb.WorkloadIdentitySPIFFE{ - Id: r.spiffeId, + Id: r.spiffeID, }, }, } diff --git a/lib/services/useracl.go b/lib/services/useracl.go index d8c312bd39c60..d468e465db3e7 100644 --- a/lib/services/useracl.go +++ b/lib/services/useracl.go @@ -122,7 +122,7 @@ type UserACL struct { FileTransferAccess bool `json:"fileTransferAccess"` // GitServers defines access to Git servers. GitServers ResourceAccess `json:"gitServers"` - //WorkloadIdentity defines access to Workload Identity + // WorkloadIdentity defines access to Workload Identity WorkloadIdentity ResourceAccess `json:"workloadIdentity"` } diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index 4bcd82badba97..4e194a844a362 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -8121,13 +8121,13 @@ func decodeSessionCookie(t *testing.T, value string) (sessionID string) { } type WebPackOptions struct { - // Number of proxies to setup (default: 1) + // Number of proxies to set up (default: 1) numProxies int proxyOptions []proxyOption enableAuthCache bool } -type webPackOptions = func(*WebPackOptions) +type webPackOptions func(*WebPackOptions) func withWebPackProxyOptions(opts ...proxyOption) webPackOptions { return func(cfg *WebPackOptions) { From 77bc7fcd47a6b4e47db8150369285f3b7917a2a5 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 10:10:27 +0100 Subject: [PATCH 11/33] Un-deprecate `newWebPack` --- lib/web/apiserver_ping_test.go | 4 ++-- lib/web/apiserver_test.go | 28 +++++----------------------- lib/web/device_trust_test.go | 2 +- lib/web/machineid_test.go | 2 +- lib/web/workload_identity_test.go | 2 +- 5 files changed, 10 insertions(+), 28 deletions(-) diff --git a/lib/web/apiserver_ping_test.go b/lib/web/apiserver_ping_test.go index 4f3bfb63cbe4c..0a9678dd3636e 100644 --- a/lib/web/apiserver_ping_test.go +++ b/lib/web/apiserver_ping_test.go @@ -254,9 +254,9 @@ func TestPing_multiProxyAddr(t *testing.T) { // TestPing_minimalAPI tests that pinging the minimal web API works correctly. func TestPing_minimalAPI(t *testing.T) { - env := newWebPack(t, 1, func(cfg *proxyConfig) { + env := newWebPack(t, 1, withWebPackProxyOptions(func(cfg *proxyConfig) { cfg.minimalHandler = true - }) + })) proxy := env.proxies[0] tests := []struct { name string diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index 4e194a844a362..196b6603a622b 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -8121,8 +8121,6 @@ func decodeSessionCookie(t *testing.T, value string) (sessionID string) { } type WebPackOptions struct { - // Number of proxies to set up (default: 1) - numProxies int proxyOptions []proxyOption enableAuthCache bool } @@ -8135,30 +8133,14 @@ func withWebPackProxyOptions(opts ...proxyOption) webPackOptions { } } -func withWebPackNumProxies(numProxies int) webPackOptions { - return func(cfg *WebPackOptions) { - cfg.numProxies = numProxies - } -} - func withWebPackAuthCacheEnabled(enable bool) webPackOptions { return func(cfg *WebPackOptions) { cfg.enableAuthCache = enable } } -func newWebPack(t *testing.T, numProxies int, opts ...proxyOption) *webPack { - return newWebPackWithOptions( - t, - withWebPackProxyOptions(opts...), - withWebPackNumProxies(numProxies), - ) -} - -func newWebPackWithOptions(t *testing.T, opts ...webPackOptions) *webPack { - options := &WebPackOptions{ - numProxies: 1, - } +func newWebPack(t *testing.T, numProxies int, opts ...webPackOptions) *webPack { + options := &WebPackOptions{} for _, opt := range opts { opt(options) @@ -8284,7 +8266,7 @@ func newWebPackWithOptions(t *testing.T, opts ...webPackOptions) *webPack { }) var proxies []*testProxy - for p := range options.numProxies { + for p := range numProxies { proxyID := fmt.Sprintf("proxy%v", p) proxies = append(proxies, createProxy(ctx, t, proxyID, node, server.TLS, hostSigners, clock, options.proxyOptions...)) } @@ -8293,11 +8275,11 @@ func newWebPackWithOptions(t *testing.T, opts ...webPackOptions) *webPack { for start := time.Now(); ; { proxies, err := proxies[0].client.GetProxies() require.NoError(t, err) - if len(proxies) == options.numProxies { + if len(proxies) == numProxies { break } if time.Since(start) > 5*time.Second { - t.Fatalf("Proxies didn't register within 5s after startup; registered: %d, want: %d", len(proxies), options.numProxies) + t.Fatalf("Proxies didn't register within 5s after startup; registered: %d, want: %d", len(proxies), numProxies) } } diff --git a/lib/web/device_trust_test.go b/lib/web/device_trust_test.go index 7a6f21a9887f6..940fe402339c4 100644 --- a/lib/web/device_trust_test.go +++ b/lib/web/device_trust_test.go @@ -40,7 +40,7 @@ func TestHandler_DeviceWebConfirm(t *testing.T) { wPack := newWebPack( t, 1, /* numProxies */ - withDevicesClientOverride(fakeDevices), + withWebPackProxyOptions(withDevicesClientOverride(fakeDevices)), ) proxy := wPack.proxies[0] diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index dc0dbf2220d0d..ba2342b40c3d5 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -759,7 +759,7 @@ func TestListBotInstancesSorting(t *testing.T) { t.Parallel() ctx := t.Context() - env := newWebPackWithOptions(t, withWebPackAuthCacheEnabled(true)) + env := newWebPack(t, 1, withWebPackAuthCacheEnabled(true)) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() diff --git a/lib/web/workload_identity_test.go b/lib/web/workload_identity_test.go index 92f7346f5d79d..c9b1a70216dfc 100644 --- a/lib/web/workload_identity_test.go +++ b/lib/web/workload_identity_test.go @@ -170,7 +170,7 @@ func TestListWorkloadIdentitiesSorting(t *testing.T) { t.Parallel() ctx := t.Context() - env := newWebPackWithOptions(t, withWebPackAuthCacheEnabled(true)) + env := newWebPack(t, 1, withWebPackAuthCacheEnabled(true)) proxy := env.proxies[0] pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) clusterName := env.server.ClusterName() From 732294c3f8cbef5ce244670ad38792c7c1aec3d7 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 10:10:57 +0100 Subject: [PATCH 12/33] Rename `KindWorkloadIdentity` --- web/packages/teleport/src/services/resources/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index 2bcebee342fbe..5c7389a07dd8d 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -303,7 +303,7 @@ export enum ResourceKind { WebToken = 'web_token', WindowsDesktop = 'windows_desktop', WindowsDesktopService = 'windows_desktop_service', - KindWorkloadIdentity = 'workload_identity', + WorkloadIdentity = 'workload_identity', // Resources that have no actual data representation, but serve for checking // access to various features. From 93553afa4c003e0f9e15d215e5b683afc3f635af Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 10:13:00 +0100 Subject: [PATCH 13/33] Add client-side API support for sort and filter --- .../src/services/workloadIdentity/workloadIdentity.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts index 69c2dfa47d6cd..f16a654c7667b 100644 --- a/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts +++ b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts @@ -25,16 +25,24 @@ export async function listWorkloadIdentities( variables: { pageToken: string; pageSize: number; + sort?: string; + searchTerm?: string; }, signal?: AbortSignal ) { - const { pageToken, pageSize } = variables; + const { pageToken, pageSize, sort, searchTerm } = variables; const path = cfg.getWorkloadIdentityUrl({ action: 'list' }); const qs = new URLSearchParams(); qs.set('page_size', pageSize.toFixed()); qs.set('page_token', pageToken); + if (sort) { + qs.set('sort', sort); + } + if (searchTerm) { + qs.set('search', searchTerm); + } const data = await api.get(`${path}?${qs.toString()}`, signal); From 01c837a96d435b600253866fecbcd35125a0bc93 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 10:13:29 +0100 Subject: [PATCH 14/33] Handle endpoint not supported scenario --- .../workloadIdentity/workloadIdentity.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts index f16a654c7667b..e85e4d405a534 100644 --- a/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts +++ b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts @@ -19,6 +19,7 @@ import cfg from 'teleport/config'; import api from '../api/api'; +import { withGenericUnsupportedError } from '../version/unsupported'; import { validateListWorkloadIdentitiesResponse } from './consts'; export async function listWorkloadIdentities( @@ -44,11 +45,16 @@ export async function listWorkloadIdentities( qs.set('search', searchTerm); } - const data = await api.get(`${path}?${qs.toString()}`, signal); + try { + const data = await api.get(`${path}?${qs.toString()}`, signal); - if (!validateListWorkloadIdentitiesResponse(data)) { - throw new Error('failed to validate list workload identities response'); - } + if (!validateListWorkloadIdentitiesResponse(data)) { + throw new Error('failed to validate list workload identities response'); + } - return data; + return data; + } catch (err) { + // TODO(nicholasmarais1158) DELETE IN v20.0.0 + withGenericUnsupportedError(err, '19.0.0'); + } } From d0479acee6671d373c687eca955b377ce5c0d55f Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 10:21:27 +0100 Subject: [PATCH 15/33] Fix cache keys for spiffe id index --- lib/cache/workload_identity.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index 52ca866e091cd..68cec2d1054fe 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -138,5 +138,6 @@ func keyForWorkloadIdentityNameIndex(r *workloadidentityv1pb.WorkloadIdentity) s } func keyForWorkloadIdentitySpiffeIDIndex(r *workloadidentityv1pb.WorkloadIdentity) string { - return r.GetSpec().GetSpiffe().GetId() + // SPIFFE IDs may not be unique, so append the resource name + return r.GetSpec().GetSpiffe().GetId() + "/" + r.GetMetadata().GetName() } From e5de35c440dccfb9f1dccf756d0b9e6135861e4b Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 15:37:02 +0100 Subject: [PATCH 16/33] Add and use `ListWorkloadIdentitiesV2` RPC --- .../v1/resource_service.pb.go | 144 +++++++++++++----- .../v1/resource_service_grpc.pb.go | 56 ++++++- .../v1/resource_service.proto | 13 ++ .../workloadidentityv1/resource_service.go | 13 ++ lib/web/workload_identity.go | 2 +- 5 files changed, 181 insertions(+), 47 deletions(-) diff --git a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go index 6fd55be1f5556..e2a864f0bda2b 100644 --- a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go +++ b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go @@ -274,13 +274,9 @@ type ListWorkloadIdentitiesRequest struct { // The server may impose a different page size at its discretion. PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` // The page_token value returned from a previous ListWorkloadIdentities request, if any. - PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` - // The sort config to use for the results. If empty, the default sort field and order is used. - Sort *types.SortBy `protobuf:"bytes,3,opt,name=sort,proto3" json:"sort,omitempty"` - // A search term used to filter the results. If non-empty, it's used to match against supported fields. - FilterSearchTerm string `protobuf:"bytes,4,opt,name=filter_search_term,json=filterSearchTerm,proto3" json:"filter_search_term,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListWorkloadIdentitiesRequest) Reset() { @@ -327,14 +323,74 @@ func (x *ListWorkloadIdentitiesRequest) GetPageToken() string { return "" } -func (x *ListWorkloadIdentitiesRequest) GetSort() *types.SortBy { +// The request for ListWorkloadIdentitiesV2. +type ListWorkloadIdentitiesV2Request struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The maximum number of items to return. + // The server may impose a different page size at its discretion. + PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // The page_token value returned from a previous ListWorkloadIdentities request, if any. + PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + // The sort config to use for the results. If empty, the default sort field and order is used. + Sort *types.SortBy `protobuf:"bytes,3,opt,name=sort,proto3" json:"sort,omitempty"` + // A search term used to filter the results. If non-empty, it's used to match against supported fields. + FilterSearchTerm string `protobuf:"bytes,4,opt,name=filter_search_term,json=filterSearchTerm,proto3" json:"filter_search_term,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWorkloadIdentitiesV2Request) Reset() { + *x = ListWorkloadIdentitiesV2Request{} + mi := &file_teleport_workloadidentity_v1_resource_service_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWorkloadIdentitiesV2Request) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWorkloadIdentitiesV2Request) ProtoMessage() {} + +func (x *ListWorkloadIdentitiesV2Request) ProtoReflect() protoreflect.Message { + mi := &file_teleport_workloadidentity_v1_resource_service_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWorkloadIdentitiesV2Request.ProtoReflect.Descriptor instead. +func (*ListWorkloadIdentitiesV2Request) Descriptor() ([]byte, []int) { + return file_teleport_workloadidentity_v1_resource_service_proto_rawDescGZIP(), []int{6} +} + +func (x *ListWorkloadIdentitiesV2Request) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListWorkloadIdentitiesV2Request) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +func (x *ListWorkloadIdentitiesV2Request) GetSort() *types.SortBy { if x != nil { return x.Sort } return nil } -func (x *ListWorkloadIdentitiesRequest) GetFilterSearchTerm() string { +func (x *ListWorkloadIdentitiesV2Request) GetFilterSearchTerm() string { if x != nil { return x.FilterSearchTerm } @@ -355,7 +411,7 @@ type ListWorkloadIdentitiesResponse struct { func (x *ListWorkloadIdentitiesResponse) Reset() { *x = ListWorkloadIdentitiesResponse{} - mi := &file_teleport_workloadidentity_v1_resource_service_proto_msgTypes[6] + mi := &file_teleport_workloadidentity_v1_resource_service_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -367,7 +423,7 @@ func (x *ListWorkloadIdentitiesResponse) String() string { func (*ListWorkloadIdentitiesResponse) ProtoMessage() {} func (x *ListWorkloadIdentitiesResponse) ProtoReflect() protoreflect.Message { - mi := &file_teleport_workloadidentity_v1_resource_service_proto_msgTypes[6] + mi := &file_teleport_workloadidentity_v1_resource_service_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -380,7 +436,7 @@ func (x *ListWorkloadIdentitiesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListWorkloadIdentitiesResponse.ProtoReflect.Descriptor instead. func (*ListWorkloadIdentitiesResponse) Descriptor() ([]byte, []int) { - return file_teleport_workloadidentity_v1_resource_service_proto_rawDescGZIP(), []int{6} + return file_teleport_workloadidentity_v1_resource_service_proto_rawDescGZIP(), []int{7} } func (x *ListWorkloadIdentitiesResponse) GetWorkloadIdentities() []*WorkloadIdentity { @@ -411,23 +467,28 @@ const file_teleport_workloadidentity_v1_resource_service_proto_rawDesc = "" + "\x1aGetWorkloadIdentityRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"3\n" + "\x1dDeleteWorkloadIdentityRequest\x12\x12\n" + - "\x04name\x18\x01 \x01(\tR\x04name\"\xac\x01\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"[\n" + "\x1dListWorkloadIdentitiesRequest\x12\x1b\n" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + + "page_token\x18\x02 \x01(\tR\tpageToken\"\xae\x01\n" + + "\x1fListWorkloadIdentitiesV2Request\x12\x1b\n" + + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + + "\n" + "page_token\x18\x02 \x01(\tR\tpageToken\x12!\n" + "\x04sort\x18\x03 \x01(\v2\r.types.SortByR\x04sort\x12,\n" + "\x12filter_search_term\x18\x04 \x01(\tR\x10filterSearchTerm\"\xa9\x01\n" + "\x1eListWorkloadIdentitiesResponse\x12_\n" + "\x13workload_identities\x18\x01 \x03(\v2..teleport.workloadidentity.v1.WorkloadIdentityR\x12workloadIdentities\x12&\n" + - "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken2\xbf\x06\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken2\xd9\a\n" + "\x1fWorkloadIdentityResourceService\x12\x85\x01\n" + "\x16CreateWorkloadIdentity\x12;.teleport.workloadidentity.v1.CreateWorkloadIdentityRequest\x1a..teleport.workloadidentity.v1.WorkloadIdentity\x12\x85\x01\n" + "\x16UpdateWorkloadIdentity\x12;.teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest\x1a..teleport.workloadidentity.v1.WorkloadIdentity\x12\x85\x01\n" + "\x16UpsertWorkloadIdentity\x12;.teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest\x1a..teleport.workloadidentity.v1.WorkloadIdentity\x12\x7f\n" + "\x13GetWorkloadIdentity\x128.teleport.workloadidentity.v1.GetWorkloadIdentityRequest\x1a..teleport.workloadidentity.v1.WorkloadIdentity\x12m\n" + "\x16DeleteWorkloadIdentity\x12;.teleport.workloadidentity.v1.DeleteWorkloadIdentityRequest\x1a\x16.google.protobuf.Empty\x12\x93\x01\n" + - "\x16ListWorkloadIdentities\x12;.teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest\x1a<.teleport.workloadidentity.v1.ListWorkloadIdentitiesResponseBdZbgithub.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1;workloadidentityv1b\x06proto3" + "\x16ListWorkloadIdentities\x12;.teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest\x1a<.teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse\x12\x97\x01\n" + + "\x18ListWorkloadIdentitiesV2\x12=.teleport.workloadidentity.v1.ListWorkloadIdentitiesV2Request\x1a<.teleport.workloadidentity.v1.ListWorkloadIdentitiesResponseBdZbgithub.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1;workloadidentityv1b\x06proto3" var ( file_teleport_workloadidentity_v1_resource_service_proto_rawDescOnce sync.Once @@ -441,39 +502,42 @@ func file_teleport_workloadidentity_v1_resource_service_proto_rawDescGZIP() []by return file_teleport_workloadidentity_v1_resource_service_proto_rawDescData } -var file_teleport_workloadidentity_v1_resource_service_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_teleport_workloadidentity_v1_resource_service_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_teleport_workloadidentity_v1_resource_service_proto_goTypes = []any{ - (*CreateWorkloadIdentityRequest)(nil), // 0: teleport.workloadidentity.v1.CreateWorkloadIdentityRequest - (*UpdateWorkloadIdentityRequest)(nil), // 1: teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest - (*UpsertWorkloadIdentityRequest)(nil), // 2: teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest - (*GetWorkloadIdentityRequest)(nil), // 3: teleport.workloadidentity.v1.GetWorkloadIdentityRequest - (*DeleteWorkloadIdentityRequest)(nil), // 4: teleport.workloadidentity.v1.DeleteWorkloadIdentityRequest - (*ListWorkloadIdentitiesRequest)(nil), // 5: teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest - (*ListWorkloadIdentitiesResponse)(nil), // 6: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse - (*WorkloadIdentity)(nil), // 7: teleport.workloadidentity.v1.WorkloadIdentity - (*types.SortBy)(nil), // 8: types.SortBy - (*emptypb.Empty)(nil), // 9: google.protobuf.Empty + (*CreateWorkloadIdentityRequest)(nil), // 0: teleport.workloadidentity.v1.CreateWorkloadIdentityRequest + (*UpdateWorkloadIdentityRequest)(nil), // 1: teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest + (*UpsertWorkloadIdentityRequest)(nil), // 2: teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest + (*GetWorkloadIdentityRequest)(nil), // 3: teleport.workloadidentity.v1.GetWorkloadIdentityRequest + (*DeleteWorkloadIdentityRequest)(nil), // 4: teleport.workloadidentity.v1.DeleteWorkloadIdentityRequest + (*ListWorkloadIdentitiesRequest)(nil), // 5: teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest + (*ListWorkloadIdentitiesV2Request)(nil), // 6: teleport.workloadidentity.v1.ListWorkloadIdentitiesV2Request + (*ListWorkloadIdentitiesResponse)(nil), // 7: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse + (*WorkloadIdentity)(nil), // 8: teleport.workloadidentity.v1.WorkloadIdentity + (*types.SortBy)(nil), // 9: types.SortBy + (*emptypb.Empty)(nil), // 10: google.protobuf.Empty } var file_teleport_workloadidentity_v1_resource_service_proto_depIdxs = []int32{ - 7, // 0: teleport.workloadidentity.v1.CreateWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity - 7, // 1: teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity - 7, // 2: teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity - 8, // 3: teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest.sort:type_name -> types.SortBy - 7, // 4: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse.workload_identities:type_name -> teleport.workloadidentity.v1.WorkloadIdentity + 8, // 0: teleport.workloadidentity.v1.CreateWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity + 8, // 1: teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity + 8, // 2: teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity + 9, // 3: teleport.workloadidentity.v1.ListWorkloadIdentitiesV2Request.sort:type_name -> types.SortBy + 8, // 4: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse.workload_identities:type_name -> teleport.workloadidentity.v1.WorkloadIdentity 0, // 5: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.CreateWorkloadIdentityRequest 1, // 6: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest 2, // 7: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest 3, // 8: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:input_type -> teleport.workloadidentity.v1.GetWorkloadIdentityRequest 4, // 9: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:input_type -> teleport.workloadidentity.v1.DeleteWorkloadIdentityRequest 5, // 10: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:input_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest - 7, // 11: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 7, // 12: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 7, // 13: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 7, // 14: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 9, // 15: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:output_type -> google.protobuf.Empty - 6, // 16: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:output_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse - 11, // [11:17] is the sub-list for method output_type - 5, // [5:11] is the sub-list for method input_type + 6, // 11: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentitiesV2:input_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesV2Request + 8, // 12: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 8, // 13: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 8, // 14: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 8, // 15: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 10, // 16: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:output_type -> google.protobuf.Empty + 7, // 17: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:output_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse + 7, // 18: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentitiesV2:output_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse + 12, // [12:19] is the sub-list for method output_type + 5, // [5:12] is the sub-list for method input_type 5, // [5:5] is the sub-list for extension type_name 5, // [5:5] is the sub-list for extension extendee 0, // [0:5] is the sub-list for field type_name @@ -491,7 +555,7 @@ func file_teleport_workloadidentity_v1_resource_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_teleport_workloadidentity_v1_resource_service_proto_rawDesc), len(file_teleport_workloadidentity_v1_resource_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 7, + NumMessages: 8, NumExtensions: 0, NumServices: 1, }, diff --git a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service_grpc.pb.go b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service_grpc.pb.go index aea5810b7f29a..b5a5b450eb248 100644 --- a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service_grpc.pb.go +++ b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service_grpc.pb.go @@ -34,12 +34,13 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - WorkloadIdentityResourceService_CreateWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/CreateWorkloadIdentity" - WorkloadIdentityResourceService_UpdateWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/UpdateWorkloadIdentity" - WorkloadIdentityResourceService_UpsertWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/UpsertWorkloadIdentity" - WorkloadIdentityResourceService_GetWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/GetWorkloadIdentity" - WorkloadIdentityResourceService_DeleteWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/DeleteWorkloadIdentity" - WorkloadIdentityResourceService_ListWorkloadIdentities_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/ListWorkloadIdentities" + WorkloadIdentityResourceService_CreateWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/CreateWorkloadIdentity" + WorkloadIdentityResourceService_UpdateWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/UpdateWorkloadIdentity" + WorkloadIdentityResourceService_UpsertWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/UpsertWorkloadIdentity" + WorkloadIdentityResourceService_GetWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/GetWorkloadIdentity" + WorkloadIdentityResourceService_DeleteWorkloadIdentity_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/DeleteWorkloadIdentity" + WorkloadIdentityResourceService_ListWorkloadIdentities_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/ListWorkloadIdentities" + WorkloadIdentityResourceService_ListWorkloadIdentitiesV2_FullMethodName = "/teleport.workloadidentity.v1.WorkloadIdentityResourceService/ListWorkloadIdentitiesV2" ) // WorkloadIdentityResourceServiceClient is the client API for WorkloadIdentityResourceService service. @@ -69,6 +70,10 @@ type WorkloadIdentityResourceServiceClient interface { // ListWorkloadIdentities of all workload identities, pagination semantics are // applied. ListWorkloadIdentities(ctx context.Context, in *ListWorkloadIdentitiesRequest, opts ...grpc.CallOption) (*ListWorkloadIdentitiesResponse, error) + // ListWorkloadIdentities of all workload identities, pagination semantics are + // applied. Sorting by name or spiffe id is supported, and results can be + // filtered using a search term + ListWorkloadIdentitiesV2(ctx context.Context, in *ListWorkloadIdentitiesV2Request, opts ...grpc.CallOption) (*ListWorkloadIdentitiesResponse, error) } type workloadIdentityResourceServiceClient struct { @@ -139,6 +144,16 @@ func (c *workloadIdentityResourceServiceClient) ListWorkloadIdentities(ctx conte return out, nil } +func (c *workloadIdentityResourceServiceClient) ListWorkloadIdentitiesV2(ctx context.Context, in *ListWorkloadIdentitiesV2Request, opts ...grpc.CallOption) (*ListWorkloadIdentitiesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListWorkloadIdentitiesResponse) + err := c.cc.Invoke(ctx, WorkloadIdentityResourceService_ListWorkloadIdentitiesV2_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // WorkloadIdentityResourceServiceServer is the server API for WorkloadIdentityResourceService service. // All implementations must embed UnimplementedWorkloadIdentityResourceServiceServer // for forward compatibility. @@ -166,6 +181,10 @@ type WorkloadIdentityResourceServiceServer interface { // ListWorkloadIdentities of all workload identities, pagination semantics are // applied. ListWorkloadIdentities(context.Context, *ListWorkloadIdentitiesRequest) (*ListWorkloadIdentitiesResponse, error) + // ListWorkloadIdentities of all workload identities, pagination semantics are + // applied. Sorting by name or spiffe id is supported, and results can be + // filtered using a search term + ListWorkloadIdentitiesV2(context.Context, *ListWorkloadIdentitiesV2Request) (*ListWorkloadIdentitiesResponse, error) mustEmbedUnimplementedWorkloadIdentityResourceServiceServer() } @@ -194,6 +213,9 @@ func (UnimplementedWorkloadIdentityResourceServiceServer) DeleteWorkloadIdentity func (UnimplementedWorkloadIdentityResourceServiceServer) ListWorkloadIdentities(context.Context, *ListWorkloadIdentitiesRequest) (*ListWorkloadIdentitiesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method ListWorkloadIdentities not implemented") } +func (UnimplementedWorkloadIdentityResourceServiceServer) ListWorkloadIdentitiesV2(context.Context, *ListWorkloadIdentitiesV2Request) (*ListWorkloadIdentitiesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListWorkloadIdentitiesV2 not implemented") +} func (UnimplementedWorkloadIdentityResourceServiceServer) mustEmbedUnimplementedWorkloadIdentityResourceServiceServer() { } func (UnimplementedWorkloadIdentityResourceServiceServer) testEmbeddedByValue() {} @@ -324,6 +346,24 @@ func _WorkloadIdentityResourceService_ListWorkloadIdentities_Handler(srv interfa return interceptor(ctx, in, info, handler) } +func _WorkloadIdentityResourceService_ListWorkloadIdentitiesV2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListWorkloadIdentitiesV2Request) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(WorkloadIdentityResourceServiceServer).ListWorkloadIdentitiesV2(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: WorkloadIdentityResourceService_ListWorkloadIdentitiesV2_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(WorkloadIdentityResourceServiceServer).ListWorkloadIdentitiesV2(ctx, req.(*ListWorkloadIdentitiesV2Request)) + } + return interceptor(ctx, in, info, handler) +} + // WorkloadIdentityResourceService_ServiceDesc is the grpc.ServiceDesc for WorkloadIdentityResourceService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -355,6 +395,10 @@ var WorkloadIdentityResourceService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListWorkloadIdentities", Handler: _WorkloadIdentityResourceService_ListWorkloadIdentities_Handler, }, + { + MethodName: "ListWorkloadIdentitiesV2", + Handler: _WorkloadIdentityResourceService_ListWorkloadIdentitiesV2_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "teleport/workloadidentity/v1/resource_service.proto", diff --git a/api/proto/teleport/workloadidentity/v1/resource_service.proto b/api/proto/teleport/workloadidentity/v1/resource_service.proto index 7312e3625d9e9..51d35c5c1b8e4 100644 --- a/api/proto/teleport/workloadidentity/v1/resource_service.proto +++ b/api/proto/teleport/workloadidentity/v1/resource_service.proto @@ -45,6 +45,10 @@ service WorkloadIdentityResourceService { // ListWorkloadIdentities of all workload identities, pagination semantics are // applied. rpc ListWorkloadIdentities(ListWorkloadIdentitiesRequest) returns (ListWorkloadIdentitiesResponse); + // ListWorkloadIdentities of all workload identities, pagination semantics are + // applied. Sorting by name or spiffe id is supported, and results can be + // filtered using a search term + rpc ListWorkloadIdentitiesV2(ListWorkloadIdentitiesV2Request) returns (ListWorkloadIdentitiesResponse); } // The request for CreateWorkloadIdentity. @@ -84,6 +88,15 @@ message ListWorkloadIdentitiesRequest { int32 page_size = 1; // The page_token value returned from a previous ListWorkloadIdentities request, if any. string page_token = 2; +} + +// The request for ListWorkloadIdentitiesV2. +message ListWorkloadIdentitiesV2Request { + // The maximum number of items to return. + // The server may impose a different page size at its discretion. + int32 page_size = 1; + // The page_token value returned from a previous ListWorkloadIdentities request, if any. + string page_token = 2; // The sort config to use for the results. If empty, the default sort field and order is used. types.SortBy sort = 3; // A search term used to filter the results. If non-empty, it's used to match against supported fields. diff --git a/lib/auth/machineid/workloadidentityv1/resource_service.go b/lib/auth/machineid/workloadidentityv1/resource_service.go index 8f01647735b8e..07abbab9dd61d 100644 --- a/lib/auth/machineid/workloadidentityv1/resource_service.go +++ b/lib/auth/machineid/workloadidentityv1/resource_service.go @@ -130,6 +130,19 @@ func (s *ResourceService) GetWorkloadIdentity( // Implements teleport.workloadidentity.v1.ResourceService/ListWorkloadIdentities func (s *ResourceService) ListWorkloadIdentities( ctx context.Context, req *workloadidentityv1pb.ListWorkloadIdentitiesRequest, +) (*workloadidentityv1pb.ListWorkloadIdentitiesResponse, error) { + return s.ListWorkloadIdentitiesV2(ctx, &workloadidentityv1pb.ListWorkloadIdentitiesV2Request{ + PageSize: req.GetPageSize(), + PageToken: req.GetPageToken(), + }) +} + +// ListWorkloadIdentitiesV2 returns a list of WorkloadIdentity resources. It +// follows the Google API design guidelines for list pagination. It supports +// sorting and filtering. +// Implements teleport.workloadidentity.v1.ResourceService/ListWorkloadIdentitiesV2 +func (s *ResourceService) ListWorkloadIdentitiesV2( + ctx context.Context, req *workloadidentityv1pb.ListWorkloadIdentitiesV2Request, ) (*workloadidentityv1pb.ListWorkloadIdentitiesResponse, error) { authCtx, err := s.authorizer.Authorize(ctx) if err != nil { diff --git a/lib/web/workload_identity.go b/lib/web/workload_identity.go index 2d557833d31b2..413b13d96d03c 100644 --- a/lib/web/workload_identity.go +++ b/lib/web/workload_identity.go @@ -54,7 +54,7 @@ func (h *Handler) listWorkloadIdentities(_ http.ResponseWriter, r *http.Request, sort = &s } - result, err := clt.WorkloadIdentityResourceServiceClient().ListWorkloadIdentities(r.Context(), &workloadidentityv1.ListWorkloadIdentitiesRequest{ + result, err := clt.WorkloadIdentityResourceServiceClient().ListWorkloadIdentitiesV2(r.Context(), &workloadidentityv1.ListWorkloadIdentitiesV2Request{ PageSize: int32(pageSize), PageToken: r.URL.Query().Get("page_token"), Sort: sort, From 90541bbeac0f817ceccd64d12d1adb21bd2d99aa Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 15:37:52 +0100 Subject: [PATCH 17/33] Return `CompareFailedError` for sorting unavailable --- lib/services/local/workload_identity.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/local/workload_identity.go b/lib/services/local/workload_identity.go index b3659e7b2589f..ec4782486fed3 100644 --- a/lib/services/local/workload_identity.go +++ b/lib/services/local/workload_identity.go @@ -84,7 +84,7 @@ func (b *WorkloadIdentityService) ListWorkloadIdentities( options *services.ListWorkloadIdentitiesRequestOptions, ) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { if options.GetSort() != nil && (options.GetSort().Field != "name" || options.GetSort().IsDesc != false) { - return nil, "", trace.BadParameter("unsupported sort, only name:asc is supported, but got %q (desc = %t)", options.Sort.Field, options.Sort.IsDesc) + return nil, "", trace.CompareFailed("unsupported sort, only name:asc is supported, but got %q (desc = %t)", options.Sort.Field, options.Sort.IsDesc) } if options.GetFilterSearchTerm() == "" { From 2c01c6d54e42413acd3c169bee27ecdbb4b2d16f Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Thu, 11 Sep 2025 15:50:45 +0100 Subject: [PATCH 18/33] Split sort into two fields (field and direction) --- .../v1/resource_service.pb.go | 78 ++++++++++--------- .../v1/resource_service.proto | 9 ++- .../workloadidentityv1/resource_service.go | 3 +- lib/cache/workload_identity.go | 10 +-- lib/cache/workload_identity_test.go | 30 +++---- lib/services/local/workload_identity.go | 7 +- lib/services/local/workload_identity_test.go | 15 ++-- lib/services/workload_identity.go | 20 +++-- lib/web/workload_identity.go | 28 +++---- lib/web/workload_identity_test.go | 3 +- 10 files changed, 108 insertions(+), 95 deletions(-) diff --git a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go index e2a864f0bda2b..334455226ba38 100644 --- a/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go +++ b/api/gen/proto/go/teleport/workloadidentity/v1/resource_service.pb.go @@ -21,7 +21,6 @@ package workloadidentityv1 import ( - types "github.com/gravitational/teleport/api/types" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" emptypb "google.golang.org/protobuf/types/known/emptypb" @@ -331,10 +330,12 @@ type ListWorkloadIdentitiesV2Request struct { PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` // The page_token value returned from a previous ListWorkloadIdentities request, if any. PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` - // The sort config to use for the results. If empty, the default sort field and order is used. - Sort *types.SortBy `protobuf:"bytes,3,opt,name=sort,proto3" json:"sort,omitempty"` + // The sort field to use for the results. If empty, the default sort field is used. + SortField string `protobuf:"bytes,3,opt,name=sort_field,json=sortField,proto3" json:"sort_field,omitempty"` + // The sort order to use for the results. If empty, the default sort order is used. + SortDesc bool `protobuf:"varint,4,opt,name=sort_desc,json=sortDesc,proto3" json:"sort_desc,omitempty"` // A search term used to filter the results. If non-empty, it's used to match against supported fields. - FilterSearchTerm string `protobuf:"bytes,4,opt,name=filter_search_term,json=filterSearchTerm,proto3" json:"filter_search_term,omitempty"` + FilterSearchTerm string `protobuf:"bytes,5,opt,name=filter_search_term,json=filterSearchTerm,proto3" json:"filter_search_term,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -383,11 +384,18 @@ func (x *ListWorkloadIdentitiesV2Request) GetPageToken() string { return "" } -func (x *ListWorkloadIdentitiesV2Request) GetSort() *types.SortBy { +func (x *ListWorkloadIdentitiesV2Request) GetSortField() string { if x != nil { - return x.Sort + return x.SortField } - return nil + return "" +} + +func (x *ListWorkloadIdentitiesV2Request) GetSortDesc() bool { + if x != nil { + return x.SortDesc + } + return false } func (x *ListWorkloadIdentitiesV2Request) GetFilterSearchTerm() string { @@ -457,7 +465,7 @@ var File_teleport_workloadidentity_v1_resource_service_proto protoreflect.FileDe const file_teleport_workloadidentity_v1_resource_service_proto_rawDesc = "" + "\n" + - "3teleport/workloadidentity/v1/resource_service.proto\x12\x1cteleport.workloadidentity.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a!teleport/legacy/types/types.proto\x1a+teleport/workloadidentity/v1/resource.proto\"|\n" + + "3teleport/workloadidentity/v1/resource_service.proto\x12\x1cteleport.workloadidentity.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a+teleport/workloadidentity/v1/resource.proto\"|\n" + "\x1dCreateWorkloadIdentityRequest\x12[\n" + "\x11workload_identity\x18\x01 \x01(\v2..teleport.workloadidentity.v1.WorkloadIdentityR\x10workloadIdentity\"|\n" + "\x1dUpdateWorkloadIdentityRequest\x12[\n" + @@ -471,13 +479,15 @@ const file_teleport_workloadidentity_v1_resource_service_proto_rawDesc = "" + "\x1dListWorkloadIdentitiesRequest\x12\x1b\n" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + - "page_token\x18\x02 \x01(\tR\tpageToken\"\xae\x01\n" + + "page_token\x18\x02 \x01(\tR\tpageToken\"\xc7\x01\n" + "\x1fListWorkloadIdentitiesV2Request\x12\x1b\n" + "\tpage_size\x18\x01 \x01(\x05R\bpageSize\x12\x1d\n" + "\n" + - "page_token\x18\x02 \x01(\tR\tpageToken\x12!\n" + - "\x04sort\x18\x03 \x01(\v2\r.types.SortByR\x04sort\x12,\n" + - "\x12filter_search_term\x18\x04 \x01(\tR\x10filterSearchTerm\"\xa9\x01\n" + + "page_token\x18\x02 \x01(\tR\tpageToken\x12\x1d\n" + + "\n" + + "sort_field\x18\x03 \x01(\tR\tsortField\x12\x1b\n" + + "\tsort_desc\x18\x04 \x01(\bR\bsortDesc\x12,\n" + + "\x12filter_search_term\x18\x05 \x01(\tR\x10filterSearchTerm\"\xa9\x01\n" + "\x1eListWorkloadIdentitiesResponse\x12_\n" + "\x13workload_identities\x18\x01 \x03(\v2..teleport.workloadidentity.v1.WorkloadIdentityR\x12workloadIdentities\x12&\n" + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken2\xd9\a\n" + @@ -513,34 +523,32 @@ var file_teleport_workloadidentity_v1_resource_service_proto_goTypes = []any{ (*ListWorkloadIdentitiesV2Request)(nil), // 6: teleport.workloadidentity.v1.ListWorkloadIdentitiesV2Request (*ListWorkloadIdentitiesResponse)(nil), // 7: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse (*WorkloadIdentity)(nil), // 8: teleport.workloadidentity.v1.WorkloadIdentity - (*types.SortBy)(nil), // 9: types.SortBy - (*emptypb.Empty)(nil), // 10: google.protobuf.Empty + (*emptypb.Empty)(nil), // 9: google.protobuf.Empty } var file_teleport_workloadidentity_v1_resource_service_proto_depIdxs = []int32{ 8, // 0: teleport.workloadidentity.v1.CreateWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity 8, // 1: teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity 8, // 2: teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest.workload_identity:type_name -> teleport.workloadidentity.v1.WorkloadIdentity - 9, // 3: teleport.workloadidentity.v1.ListWorkloadIdentitiesV2Request.sort:type_name -> types.SortBy - 8, // 4: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse.workload_identities:type_name -> teleport.workloadidentity.v1.WorkloadIdentity - 0, // 5: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.CreateWorkloadIdentityRequest - 1, // 6: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest - 2, // 7: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest - 3, // 8: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:input_type -> teleport.workloadidentity.v1.GetWorkloadIdentityRequest - 4, // 9: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:input_type -> teleport.workloadidentity.v1.DeleteWorkloadIdentityRequest - 5, // 10: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:input_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest - 6, // 11: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentitiesV2:input_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesV2Request - 8, // 12: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 8, // 13: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 8, // 14: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 8, // 15: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity - 10, // 16: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:output_type -> google.protobuf.Empty - 7, // 17: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:output_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse - 7, // 18: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentitiesV2:output_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse - 12, // [12:19] is the sub-list for method output_type - 5, // [5:12] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 8, // 3: teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse.workload_identities:type_name -> teleport.workloadidentity.v1.WorkloadIdentity + 0, // 4: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.CreateWorkloadIdentityRequest + 1, // 5: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpdateWorkloadIdentityRequest + 2, // 6: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:input_type -> teleport.workloadidentity.v1.UpsertWorkloadIdentityRequest + 3, // 7: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:input_type -> teleport.workloadidentity.v1.GetWorkloadIdentityRequest + 4, // 8: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:input_type -> teleport.workloadidentity.v1.DeleteWorkloadIdentityRequest + 5, // 9: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:input_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesRequest + 6, // 10: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentitiesV2:input_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesV2Request + 8, // 11: teleport.workloadidentity.v1.WorkloadIdentityResourceService.CreateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 8, // 12: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpdateWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 8, // 13: teleport.workloadidentity.v1.WorkloadIdentityResourceService.UpsertWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 8, // 14: teleport.workloadidentity.v1.WorkloadIdentityResourceService.GetWorkloadIdentity:output_type -> teleport.workloadidentity.v1.WorkloadIdentity + 9, // 15: teleport.workloadidentity.v1.WorkloadIdentityResourceService.DeleteWorkloadIdentity:output_type -> google.protobuf.Empty + 7, // 16: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentities:output_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse + 7, // 17: teleport.workloadidentity.v1.WorkloadIdentityResourceService.ListWorkloadIdentitiesV2:output_type -> teleport.workloadidentity.v1.ListWorkloadIdentitiesResponse + 11, // [11:18] is the sub-list for method output_type + 4, // [4:11] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_teleport_workloadidentity_v1_resource_service_proto_init() } diff --git a/api/proto/teleport/workloadidentity/v1/resource_service.proto b/api/proto/teleport/workloadidentity/v1/resource_service.proto index 51d35c5c1b8e4..9c72ecadc4577 100644 --- a/api/proto/teleport/workloadidentity/v1/resource_service.proto +++ b/api/proto/teleport/workloadidentity/v1/resource_service.proto @@ -17,7 +17,6 @@ syntax = "proto3"; package teleport.workloadidentity.v1; import "google/protobuf/empty.proto"; -import "teleport/legacy/types/types.proto"; import "teleport/workloadidentity/v1/resource.proto"; option go_package = "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1;workloadidentityv1"; @@ -97,10 +96,12 @@ message ListWorkloadIdentitiesV2Request { int32 page_size = 1; // The page_token value returned from a previous ListWorkloadIdentities request, if any. string page_token = 2; - // The sort config to use for the results. If empty, the default sort field and order is used. - types.SortBy sort = 3; + // The sort field to use for the results. If empty, the default sort field is used. + string sort_field = 3; + // The sort order to use for the results. If empty, the default sort order is used. + bool sort_desc = 4; // A search term used to filter the results. If non-empty, it's used to match against supported fields. - string filter_search_term = 4; + string filter_search_term = 5; } // The response for ListWorkloadIdentities. diff --git a/lib/auth/machineid/workloadidentityv1/resource_service.go b/lib/auth/machineid/workloadidentityv1/resource_service.go index 07abbab9dd61d..9d074256722eb 100644 --- a/lib/auth/machineid/workloadidentityv1/resource_service.go +++ b/lib/auth/machineid/workloadidentityv1/resource_service.go @@ -157,7 +157,8 @@ func (s *ResourceService) ListWorkloadIdentitiesV2( int(req.PageSize), req.PageToken, &services.ListWorkloadIdentitiesRequestOptions{ - Sort: req.GetSort(), + SortField: req.GetSortField(), + SortDesc: req.GetSortDesc(), FilterSearchTerm: req.GetFilterSearchTerm(), }, ) diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index 68cec2d1054fe..e36a9b48cbb3f 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -83,11 +83,9 @@ func (c *Cache) ListWorkloadIdentities( index := workloadIdentityNameIndex keyFn := keyForWorkloadIdentityNameIndex - var isDesc bool - if options.GetSort() != nil { - isDesc = options.Sort.IsDesc - - switch options.Sort.Field { + isDesc := options.GetSortDesc() + if options.GetSortField() != "" { + switch options.GetSortField() { case "name": index = workloadIdentityNameIndex keyFn = keyForWorkloadIdentityNameIndex @@ -95,7 +93,7 @@ func (c *Cache) ListWorkloadIdentities( index = workloadIdentitySpiffeIDIndex keyFn = keyForWorkloadIdentitySpiffeIDIndex default: - return nil, "", trace.BadParameter("unsupported sort %q but expected name or spiffe_id", options.Sort.Field) + return nil, "", trace.BadParameter("unsupported sort %q but expected name or spiffe_id", options.GetSortField()) } } diff --git a/lib/cache/workload_identity_test.go b/lib/cache/workload_identity_test.go index d5b8e7b598814..b4e0e83ba4b1b 100644 --- a/lib/cache/workload_identity_test.go +++ b/lib/cache/workload_identity_test.go @@ -123,10 +123,8 @@ func TestWorkloadIdentityCacheSorting(t *testing.T) { t.Run("sort ascending by spiffe_id", func(t *testing.T) { results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ - Sort: &types.SortBy{ - Field: "spiffe_id", - IsDesc: false, - }, + SortField: "spiffe_id", + SortDesc: false, }) require.NoError(t, err) require.Len(t, results, 3) @@ -137,10 +135,8 @@ func TestWorkloadIdentityCacheSorting(t *testing.T) { t.Run("sort descending by spiffe_id", func(t *testing.T) { results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ - Sort: &types.SortBy{ - Field: "spiffe_id", - IsDesc: true, - }, + SortField: "spiffe_id", + SortDesc: true, }) require.NoError(t, err) require.Len(t, results, 3) @@ -160,10 +156,8 @@ func TestWorkloadIdentityCacheSorting(t *testing.T) { t.Run("sort descending by name", func(t *testing.T) { results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ - Sort: &types.SortBy{ - Field: "name", - IsDesc: true, - }, + SortField: "name", + SortDesc: true, }) require.NoError(t, err) require.Len(t, results, 3) @@ -208,10 +202,8 @@ func TestWorkloadIdentityCacheFallback(t *testing.T) { t.Run("supported sort", func(t *testing.T) { results, _, err := p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ - Sort: &types.SortBy{ - Field: "name", - IsDesc: false, - }, + SortField: "name", + SortDesc: false, }) require.NoError(t, err) // asc by name is the only sort supported by the upstream require.Len(t, results, 1) @@ -219,10 +211,8 @@ func TestWorkloadIdentityCacheFallback(t *testing.T) { t.Run("unsupported sort", func(t *testing.T) { _, _, err = p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ - Sort: &types.SortBy{ - Field: "name", - IsDesc: true, - }, + SortField: "name", + SortDesc: true, }) require.ErrorContains(t, err, "unsupported sort, only name:asc is supported, but got \"name\" (desc = true)") }) diff --git a/lib/services/local/workload_identity.go b/lib/services/local/workload_identity.go index ec4782486fed3..e1966a60f2f12 100644 --- a/lib/services/local/workload_identity.go +++ b/lib/services/local/workload_identity.go @@ -83,8 +83,11 @@ func (b *WorkloadIdentityService) ListWorkloadIdentities( currentToken string, options *services.ListWorkloadIdentitiesRequestOptions, ) ([]*workloadidentityv1pb.WorkloadIdentity, string, error) { - if options.GetSort() != nil && (options.GetSort().Field != "name" || options.GetSort().IsDesc != false) { - return nil, "", trace.CompareFailed("unsupported sort, only name:asc is supported, but got %q (desc = %t)", options.Sort.Field, options.Sort.IsDesc) + if options.GetSortField() != "" && options.GetSortField() != "name" { + return nil, "", trace.CompareFailed("unsupported sort, only name field is supported, but got %q", options.GetSortField()) + } + if options.GetSortDesc() { + return nil, "", trace.CompareFailed("unsupported sort, only ascending order is supported") } if options.GetFilterSearchTerm() == "" { diff --git a/lib/services/local/workload_identity_test.go b/lib/services/local/workload_identity_test.go index 4970511a2b750..99bf6749d91ac 100644 --- a/lib/services/local/workload_identity_test.go +++ b/lib/services/local/workload_identity_test.go @@ -208,14 +208,17 @@ func TestWorkloadIdentityService_ListWorkloadIdentities(t *testing.T) { prevName = page[i].GetMetadata().GetName() } }) - t.Run("unsupported sort error", func(t *testing.T) { + t.Run("unsupported sort field error", func(t *testing.T) { _, _, err := service.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ - Sort: &types.SortBy{ - Field: "name", - IsDesc: true, - }, + SortField: "blah", + }) + require.ErrorContains(t, err, `unsupported sort, only name field is supported, but got "blah"`) + }) + t.Run("unsupported sort order error", func(t *testing.T) { + _, _, err := service.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + SortDesc: true, }) - require.ErrorContains(t, err, `unsupported sort, only name:asc is supported, but got "name" (desc = true)`, err.Error()) + require.ErrorContains(t, err, "unsupported sort, only ascending order is supported") }) t.Run("search filter match on name", func(t *testing.T) { page, _, err := service.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ diff --git a/lib/services/workload_identity.go b/lib/services/workload_identity.go index 27645bc969094..0deda1e568327 100644 --- a/lib/services/workload_identity.go +++ b/lib/services/workload_identity.go @@ -139,18 +139,26 @@ func ValidateWorkloadIdentity(s *workloadidentityv1pb.WorkloadIdentity) error { } type ListWorkloadIdentitiesRequestOptions struct { - // The sort config to use for the results. If empty, the default sort field - // and order is used. - Sort *types.SortBy + // The sort field to use for the results. If empty, the default sort field is used. + SortField string + // The sort order to use for the results. If empty, the default sort order is used. + SortDesc bool // A search term used to filter the results. If non-empty, it's used to match against supported fields. FilterSearchTerm string } -func (o *ListWorkloadIdentitiesRequestOptions) GetSort() *types.SortBy { +func (o *ListWorkloadIdentitiesRequestOptions) GetSortField() string { if o == nil { - return nil + return "" + } + return o.SortField +} + +func (o *ListWorkloadIdentitiesRequestOptions) GetSortDesc() bool { + if o == nil { + return false } - return o.Sort + return o.SortDesc } func (o *ListWorkloadIdentitiesRequestOptions) GetFilterSearchTerm() string { diff --git a/lib/web/workload_identity.go b/lib/web/workload_identity.go index 413b13d96d03c..a6ca8ec8d88a8 100644 --- a/lib/web/workload_identity.go +++ b/lib/web/workload_identity.go @@ -21,12 +21,12 @@ package web import ( "net/http" "strconv" + "strings" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" workloadidentityv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/workloadidentity/v1" - "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/reversetunnelclient" tslices "github.com/gravitational/teleport/lib/utils/slices" ) @@ -39,27 +39,27 @@ func (h *Handler) listWorkloadIdentities(_ http.ResponseWriter, r *http.Request, return nil, trace.Wrap(err) } - var pageSize int64 = 20 + request := &workloadidentityv1.ListWorkloadIdentitiesV2Request{ + PageSize: 20, + PageToken: r.URL.Query().Get("page_token"), + SortField: r.URL.Query().Get("sort_field"), + FilterSearchTerm: r.URL.Query().Get("search"), + } + if r.URL.Query().Has("page_size") { - pageSize, err = strconv.ParseInt(r.URL.Query().Get("page_size"), 10, 32) + pageSize, err := strconv.ParseInt(r.URL.Query().Get("page_size"), 10, 32) if err != nil { return nil, trace.BadParameter("invalid page size") } + request.PageSize = int32(pageSize) } - var sort *types.SortBy - if r.URL.Query().Has("sort") { - sortString := r.URL.Query().Get("sort") - s := types.GetSortByFromString(sortString) - sort = &s + if r.URL.Query().Has("sort_dir") { + sortDir := r.URL.Query().Get("sort_dir") + request.SortDesc = strings.ToLower(sortDir) == "desc" } - result, err := clt.WorkloadIdentityResourceServiceClient().ListWorkloadIdentitiesV2(r.Context(), &workloadidentityv1.ListWorkloadIdentitiesV2Request{ - PageSize: int32(pageSize), - PageToken: r.URL.Query().Get("page_token"), - Sort: sort, - FilterSearchTerm: r.URL.Query().Get("search"), - }) + result, err := clt.WorkloadIdentityResourceServiceClient().ListWorkloadIdentitiesV2(r.Context(), request) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/web/workload_identity_test.go b/lib/web/workload_identity_test.go index c9b1a70216dfc..c79cca16e5310 100644 --- a/lib/web/workload_identity_test.go +++ b/lib/web/workload_identity_test.go @@ -200,7 +200,8 @@ func TestListWorkloadIdentitiesSorting(t *testing.T) { response, err := pack.clt.Get(ctx, endpoint, url.Values{ "page_token": []string{""}, // default to the start "page_size": []string{"0"}, - "sort": []string{"spiffe_id:desc"}, + "sort_field": []string{"spiffe_id"}, + "sort_dir": []string{"DESC"}, }) require.NoError(t, err) assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") From ac445a26afa9004f2e3d282212ef5a95ab7970a0 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Thu, 11 Sep 2025 16:16:21 +0100 Subject: [PATCH 19/33] Update unsupported sort tests --- lib/cache/workload_identity_test.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/cache/workload_identity_test.go b/lib/cache/workload_identity_test.go index b4e0e83ba4b1b..1d96048872571 100644 --- a/lib/cache/workload_identity_test.go +++ b/lib/cache/workload_identity_test.go @@ -209,12 +209,18 @@ func TestWorkloadIdentityCacheFallback(t *testing.T) { require.Len(t, results, 1) }) - t.Run("unsupported sort", func(t *testing.T) { + t.Run("unsupported sort field", func(t *testing.T) { _, _, err = p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ - SortField: "name", - SortDesc: true, + SortField: "spiffe_id", + }) + require.ErrorContains(t, err, `unsupported sort, only name field is supported, but got "spiffe_id"`) + }) + + t.Run("unsupported sort dir", func(t *testing.T) { + _, _, err = p.cache.ListWorkloadIdentities(ctx, 0, "", &services.ListWorkloadIdentitiesRequestOptions{ + SortDesc: true, }) - require.ErrorContains(t, err, "unsupported sort, only name:asc is supported, but got \"name\" (desc = true)") + require.ErrorContains(t, err, "unsupported sort, only ascending order is supported") }) } From a90cb3d85efa25f4c688a9547e14ae1a6548889a Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 12:25:44 +0100 Subject: [PATCH 20/33] Make `updateQuery` callback optional on `SearchPanel` --- .../shared/components/Search/SearchPanel.tsx | 12 ++++++------ .../src/BotInstances/List/BotInstancesList.tsx | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/web/packages/shared/components/Search/SearchPanel.tsx b/web/packages/shared/components/Search/SearchPanel.tsx index d303d3579b0c5..ef677d4bcfcf1 100644 --- a/web/packages/shared/components/Search/SearchPanel.tsx +++ b/web/packages/shared/components/Search/SearchPanel.tsx @@ -36,8 +36,8 @@ export function SearchPanel({ hideAdvancedSearch, extraChildren, }: { - updateQuery(s: string): void; - updateSearch(s: string): void; + updateQuery?: (s: string) => void; + updateSearch: (s: string) => void; pageIndicators?: { from: number; to: number; total: number }; filter: ResourceFilter; disableSearch: boolean; @@ -60,11 +60,11 @@ export function SearchPanel({ setQuery(newQuery); if (isAdvancedSearch) { - updateQuery(newQuery); + updateQuery?.(newQuery); return; } - updateSearch(newQuery); + updateSearch?.(newQuery); } return ( @@ -85,13 +85,13 @@ export function SearchPanel({ searchValue={query} setSearchValue={updateQueryForRefetching} > - {!hideAdvancedSearch && ( + {!hideAdvancedSearch ? ( - )} + ) : undefined} diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index bade432d1792b..5ed3a6b075110 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -83,7 +83,6 @@ export function BotInstancesList({ serversideSearchPanel: ( Date: Wed, 10 Sep 2025 12:38:31 +0100 Subject: [PATCH 21/33] Add workload identities list --- .../List/WorkloadIdentitiesList.tsx | 139 +++++++++ .../WorkloadIdentity/WorkloadIdentities.tsx | 277 ++++++++++++++++++ web/packages/teleport/src/config.ts | 6 +- web/packages/teleport/src/features.tsx | 16 +- .../workloadIdentity/workloadIdentity.ts | 12 +- 5 files changed, 437 insertions(+), 13 deletions(-) create mode 100644 web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx create mode 100644 web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx diff --git a/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx b/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx new file mode 100644 index 0000000000000..844febc8a17dc --- /dev/null +++ b/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx @@ -0,0 +1,139 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { ReactElement } from 'react'; +import styled from 'styled-components'; + +import { Cell, LabelCell } from 'design/DataTable/Cells'; +import Table from 'design/DataTable/Table'; +import { FetchingConfig, SortType } from 'design/DataTable/types'; +import Flex from 'design/Flex'; +import Text from 'design/Text'; +import { SearchPanel } from 'shared/components/Search/SearchPanel'; +import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; + +import { WorkloadIdentity } from 'teleport/services/workloadIdentity/types'; + +export function WorkloadIdetitiesList({ + data, + fetchStatus, + onFetchNext, + onFetchPrev, + sortType, + onSortChanged, + searchTerm, + onSearchChange, +}: { + data: WorkloadIdentity[]; + sortType: SortType; + onSortChanged: (sortType: SortType) => void; + searchTerm: string; + onSearchChange: (term: string) => void; +} & Omit) { + const tableData = data.map(d => ({ + ...d, + spiffe_hint: valueOrEmpty(d.spiffe_hint), + })); + + return ( + + data={tableData} + fetching={{ + fetchStatus, + onFetchNext, + onFetchPrev, + disableLoadingIndicator: true, + }} + serversideProps={{ + sort: sortType, + setSort: onSortChanged, + serversideSearchPanel: ( + + ), + }} + columns={[ + { + key: 'name', + headerText: 'Name', + isSortable: true, + }, + { + key: 'spiffe_id', + headerText: 'SPIFFE ID', + isSortable: true, + render: ({ spiffe_id }) => { + return spiffe_id ? ( + + + + {spiffe_id + .split('/') + .reduce<(ReactElement | string)[]>((acc, cur, i) => { + if (i === 0) { + acc.push(cur); + } else { + // Add break opportunities after each slash + acc.push('/', , cur); + } + return acc; + }, [])} + + + + + ) : ( + {valueOrEmpty(spiffe_id)} + ); + }, + }, + { + key: 'labels', + headerText: 'Labels', + isSortable: false, + render: ({ labels: labelsMap }) => { + const labels = labelsMap ? Object.entries(labelsMap) : undefined; + return labels?.length ? ( + `${k}: ${v || '-'}`)} /> + ) : ( + {valueOrEmpty('')} + ); + }, + }, + { + key: 'spiffe_hint', + headerText: 'Hint', + isSortable: false, + }, + ]} + emptyText="No workload identities found" + /> + ); +} + +const MonoText = styled(Text)` + font-family: ${({ theme }) => theme.fonts.mono}; +`; + +function valueOrEmpty(value: string | null | undefined, empty = '-') { + return value || empty; +} diff --git a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx new file mode 100644 index 0000000000000..d5a9376f1506a --- /dev/null +++ b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx @@ -0,0 +1,277 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useHistory, useLocation } from 'react-router'; + +import { Alert } from 'design/Alert/Alert'; +import Box from 'design/Box/Box'; +import { SortType } from 'design/DataTable/types'; +import { Indicator } from 'design/Indicator/Indicator'; +import { + InfoExternalTextLink, + InfoGuideButton, + InfoParagraph, + ReferenceLinks, +} from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; + +import { + FeatureBox, + FeatureHeader, + FeatureHeaderTitle, +} from 'teleport/components/Layout/Layout'; +import { listWorkloadIdentities } from 'teleport/services/workloadIdentity/workloadIdentity'; +import useTeleport from 'teleport/useTeleport'; + +import { EmptyState } from './EmptyState/EmptyState'; +import { WorkloadIdetitiesList } from './List/WorkloadIdentitiesList'; + +export function WorkloadIdentities() { + const history = useHistory(); + const location = useLocation<{ prevPageTokens?: readonly string[] }>(); + const queryParams = new URLSearchParams(location.search); + const pageToken = queryParams.get('page') ?? ''; + const sortField = queryParams.get('sort_field') || 'name'; + const sortDir = queryParams.get('sort_dir') || 'ASC'; + const searchTerm = queryParams.get('search') ?? ''; + + const ctx = useTeleport(); + const flags = ctx.getFeatureFlags(); + const canList = flags.listWorkloadIdentities; + + const { isPending, isFetching, isSuccess, isError, error, data } = useQuery({ + enabled: canList, + queryKey: [ + 'workload_identities', + 'list', + pageToken, + sortField, + sortDir, + searchTerm, + ], + queryFn: () => + listWorkloadIdentities({ + pageSize: 20, + pageToken, + sortField, + sortDir, + searchTerm, + }), + placeholderData: keepPreviousData, + staleTime: 30_000, // Cached pages are valid for 30 seconds + }); + + const { prevPageTokens = [] } = location.state ?? {}; + const hasNextPage = !!data?.next_page_token; + const hasPrevPage = !!pageToken; + + const handleFetchNext = useCallback(() => { + const search = new URLSearchParams(location.search); + search.set('page', data?.next_page_token ?? ''); + + history.replace( + { + pathname: location.pathname, + search: search.toString(), + }, + { + prevPageTokens: [...prevPageTokens, pageToken], + } + ); + }, [ + data?.next_page_token, + history, + location.pathname, + location.search, + pageToken, + prevPageTokens, + ]); + + const handleFetchPrev = useCallback(() => { + const prevTokens = [...prevPageTokens]; + const nextToken = prevTokens.pop(); + + const search = new URLSearchParams(location.search); + search.set('page', nextToken ?? ''); + + history.replace( + { + pathname: location.pathname, + search: search.toString(), + }, + { + prevPageTokens: prevTokens, + } + ); + }, [history, location.pathname, location.search]); + + const sortType: SortType = { + fieldName: sortField, + dir: sortDir.toLowerCase() === 'desc' ? 'DESC' : 'ASC', + }; + + const handleSortChanged = useCallback( + (sortType: SortType) => { + const search = new URLSearchParams(location.search); + search.set('sort_field', sortType.fieldName); + search.set('sort_dir', sortType.dir); + search.set('page', ''); + + history.replace({ + pathname: location.pathname, + search: search.toString(), + }); + }, + [history, location.pathname, location.search] + ); + + const handleSearchChange = useCallback( + (term: string) => { + const search = new URLSearchParams(location.search); + search.set('search', term); + search.set('page', ''); + + history.replace({ + pathname: `${location.pathname}`, + search: search.toString(), + }); + }, + [history, location.pathname, location.search] + ); + + const hasUnsupportedSortError = isError && isUnsupportedSortError(error); + + if (!canList) { + return ( + + + You do not have permission to access Workload Identities. Missing role + permissions: workload_identity.list + + + + ); + } + + const isFiltering = !!queryParams.get('search'); + + if (isSuccess && !data.items?.length && !isFiltering) { + return ( + + + + ); + } + + return ( + + + Workload Identities + }} /> + + + {isPending ? ( + + + + ) : undefined} + + {isError && hasUnsupportedSortError ? ( + { + handleSortChanged({ fieldName: 'name', dir: 'ASC' }); + }, + }} + > + {error.message} + + ) : undefined} + + {isError && !hasUnsupportedSortError ? ( + {error.message} + ) : undefined} + + {isSuccess ? ( + + ) : undefined} + + ); +} + +const InfoGuide = () => ( + + + Teleport{' '} + + Workload Identity + {' '} + securely issues short-lived cryptographic identities to workloads. It is a + flexible foundation for workload identity across your infrastructure, + creating a uniform way for your workloads to authenticate regardless of + where they are running. + + + Teleport Workload Identity is compatible with the open-source{' '} + + Secure Production Identity Framework For Everyone (SPIFFE) + {' '} + standard. This enables interoperability between workload identity + implementations and also provides a wealth of off-the-shelf tools and SDKs + to simplify integration with your workloads. + + + +); + +const InfoGuideReferenceLinks = { + WorkloadIdentity: { + title: 'Workload Identity', + href: 'https://goteleport.com/docs/machine-workload-identity/workload-identity', + }, + Spiffe: { + title: 'Introduction to SPIFFE', + href: 'https://goteleport.com/docs/machine-workload-identity/workload-identity/spiffe/', + }, + GettingStarted: { + title: 'Getting Started with Workload Identity', + href: 'https://goteleport.com/docs/machine-workload-identity/workload-identity/getting-started/', + }, +}; + +const isUnsupportedSortError = (error: Error) => { + return !!error?.message && error.message.includes('unsupported sort'); +}; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index de559f64839fd..377ea9c8e9fc2 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -170,7 +170,6 @@ const cfg = { joinTokens: '/web/tokens', deviceTrust: `/web/devices`, deviceTrustAuthorize: '/web/device/authorize/:id?/:token?', - workloadIdentity: `/web/workloadidentity`, sso: '/web/sso', cluster: '/web/cluster/:clusterId/', clusters: '/web/clusters', @@ -191,6 +190,7 @@ const cfg = { botInstances: '/web/bots/instances', botInstance: '/web/bot/:botName/instance/:instanceId', botsNew: '/web/bots/new/:type?', + workloadIdentities: '/web/workloadidentities', console: '/web/cluster/:clusterId/console', consoleNodes: '/web/cluster/:clusterId/console/nodes', consoleConnect: '/web/cluster/:clusterId/console/node/:serverId/:login', @@ -824,6 +824,10 @@ const cfg = { return generatePath(cfg.routes.botInstances); }, + getWorkloadIdentitiesRoute() { + return generatePath(cfg.routes.workloadIdentities); + }, + getBotInstanceDetailsRoute(params: { botName: string; instanceId: string }) { return generatePath(cfg.routes.botInstance, params); }, diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index f631e078c8e5c..1dceb5391b44d 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -71,7 +71,7 @@ import { TrustedClusters } from './TrustedClusters'; import { NavTitle, type FeatureFlags, type TeleportFeature } from './types'; import { UnifiedResources } from './UnifiedResources'; import { Users } from './Users'; -import { EmptyState as WorkloadIdentityEmptyState } from './WorkloadIdentity/EmptyState/EmptyState'; +import { WorkloadIdentities } from './WorkloadIdentity/WorkloadIdentities'; // to promote feature discoverability, most features should be visible in the navigation even if a user doesnt have access. // However, there are some cases where hiding the feature is explicitly requested. Use this as a backdoor to hide the features that @@ -694,16 +694,16 @@ export class FeatureWorkloadIdentity implements TeleportFeature { category = NavigationCategory.MachineWorkloadId; route = { title: 'Workload Identity', - path: cfg.routes.workloadIdentity, + path: cfg.routes.workloadIdentities, exact: true, - component: WorkloadIdentityEmptyState, + component: WorkloadIdentities, }; - // for now, workload identity page is just a placeholder so everyone has - // access, unless feature hiding is off - hasAccess(): boolean { + hasAccess(flags: FeatureFlags): boolean { + // if feature hiding is enabled, only show + // if the user has access if (shouldHideFromNavigation(cfg)) { - return false; + return flags.listWorkloadIdentities; } return true; } @@ -711,7 +711,7 @@ export class FeatureWorkloadIdentity implements TeleportFeature { title: NavTitle.WorkloadIdentity, icon: License, getLink() { - return cfg.routes.workloadIdentity; + return cfg.routes.workloadIdentities; }, searchableTags: ['workload identity', 'workload', 'identity'], }; diff --git a/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts index e85e4d405a534..850d5ca8e6832 100644 --- a/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts +++ b/web/packages/teleport/src/services/workloadIdentity/workloadIdentity.ts @@ -26,20 +26,24 @@ export async function listWorkloadIdentities( variables: { pageToken: string; pageSize: number; - sort?: string; + sortField: string; + sortDir: string; searchTerm?: string; }, signal?: AbortSignal ) { - const { pageToken, pageSize, sort, searchTerm } = variables; + const { pageToken, pageSize, sortField, sortDir, searchTerm } = variables; const path = cfg.getWorkloadIdentityUrl({ action: 'list' }); const qs = new URLSearchParams(); qs.set('page_size', pageSize.toFixed()); qs.set('page_token', pageToken); - if (sort) { - qs.set('sort', sort); + if (sortField) { + qs.set('sort_field', sortField); + } + if (sortDir) { + qs.set('sort_dir', sortDir); } if (searchTerm) { qs.set('search', searchTerm); From aaa84a126b4efaa1937edb3197fd39b29d2ed06c Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 12:39:26 +0100 Subject: [PATCH 22/33] Add tests --- .../WorkloadIdentities.test.tsx | 409 ++++++++++++++++++ .../src/test/helpers/workloadIdentities.ts | 11 +- 2 files changed, 415 insertions(+), 5 deletions(-) create mode 100644 web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.test.tsx diff --git a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.test.tsx b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.test.tsx new file mode 100644 index 0000000000000..48a9e788c76c1 --- /dev/null +++ b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.test.tsx @@ -0,0 +1,409 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { QueryClientProvider } from '@tanstack/react-query'; +import { setupServer } from 'msw/node'; +import { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router'; + +import { darkTheme } from 'design/theme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { + fireEvent, + render, + screen, + testQueryClient, + userEvent, + waitFor, + waitForElementToBeRemoved, +} from 'design/utils/testing'; +import { InfoGuidePanelProvider } from 'shared/components/SlidingSidePanel/InfoGuide'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; +import { listWorkloadIdentities } from 'teleport/services/workloadIdentity/workloadIdentity'; +import { + listWorkloadIdentitiesError, + listWorkloadIdentitiesSuccess, +} from 'teleport/test/helpers/workloadIdentities'; + +import { ContextProvider } from '..'; +import { WorkloadIdentities } from './WorkloadIdentities'; + +jest.mock('teleport/services/workloadIdentity/workloadIdentity', () => { + const actual = jest.requireActual( + 'teleport/services/workloadIdentity/workloadIdentity' + ); + return { + listWorkloadIdentities: jest.fn((...all) => { + return actual.listWorkloadIdentities(...all); + }), + }; +}); + +const server = setupServer(); + +beforeAll(() => { + server.listen(); +}); + +afterEach(async () => { + server.resetHandlers(); + await testQueryClient.resetQueries(); + + jest.clearAllMocks(); +}); + +afterAll(() => server.close()); + +describe('WorkloadIdentities', () => { + it('Shows an empty state', async () => { + server.use( + listWorkloadIdentitiesSuccess({ + items: [], + next_page_token: '', + }) + ); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(screen.getByText('What is Workload Identity')).toBeInTheDocument(); + }); + + it('Shows an error state', async () => { + server.use(listWorkloadIdentitiesError(500, 'server error')); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(screen.getByText('server error')).toBeInTheDocument(); + }); + + it('Shows an unsupported sort error state', async () => { + server.use(listWorkloadIdentitiesSuccess()); + + render(, { + wrapper: makeWrapper(), + }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const testErrorMessage = + 'unsupported sort, only name:asc is supported, but got "blah" (desc = true)'; + server.use(listWorkloadIdentitiesError(400, testErrorMessage)); + + fireEvent.click(screen.getByText('SPIFFE ID')); + + await waitFor(() => { + expect(screen.getByText(testErrorMessage)).toBeInTheDocument(); + }); + + server.use(listWorkloadIdentitiesSuccess()); + + const resetButton = screen.getByText('Reset sort'); + expect(resetButton).toBeInTheDocument(); + fireEvent.click(resetButton); + + await waitFor(() => { + expect(screen.queryByText(testErrorMessage)).not.toBeInTheDocument(); + }); + }); + + it('Shows an unauthorised error state', async () => { + render(, { + wrapper: makeWrapper( + makeAcl({ + workloadIdentity: { + ...defaultAccess, + list: false, + }, + }) + ), + }); + + expect( + screen.getByText( + 'You do not have permission to access Workload Identities. Missing role permissions:', + { exact: false } + ) + ).toBeInTheDocument(); + + expect(screen.getByText('workload_identity.list')).toBeInTheDocument(); + }); + + it('Shows a list', async () => { + server.use(listWorkloadIdentitiesSuccess()); + + render(, { + wrapper: makeWrapper(), + }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(screen.getByText('test-workload-identity-1')).toBeInTheDocument(); + expect( + screen.getByText('/test/spiffe/abb53fc8-eba6-40a9-8801-221db41f3c21') + ).toBeInTheDocument(); + expect(screen.getByText('test-label-1: test-value-1')).toBeInTheDocument(); + expect(screen.getByText('test-label-2: test-value-2')).toBeInTheDocument(); + expect(screen.getByText('test-label-3: test-value-3')).toBeInTheDocument(); + expect( + screen.getByText( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.' + ) + ).toBeInTheDocument(); + }); + + it('Allows paging', async () => { + jest.mocked(listWorkloadIdentities).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + items: [ + { + name: 'test-workload-identity-1', + spiffe_id: '/test/spiffe/abb53fc8-eba6-40a9-8801-221db41f3c21', + spiffe_hint: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + labels: { + 'test-label-1': 'test-value-1', + 'test-label-2': 'test-value-2', + 'test-label-3': 'test-value-3', + }, + }, + ], + next_page_token: pageToken + '.next', + }); + }) + ); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(0); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const [nextButton] = screen.getAllByTitle('Next page'); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(1); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(2); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '.next', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '.next.next', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + const [prevButton] = screen.getAllByTitle('Previous page'); + + await waitFor(() => expect(prevButton).toBeEnabled()); + fireEvent.click(prevButton); + + // This page's data will have been cached + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + + await waitFor(() => expect(prevButton).toBeEnabled()); + fireEvent.click(prevButton); + + // This page's data will have been cached + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + }); + + it('Allows filtering (search)', async () => { + jest.mocked(listWorkloadIdentities).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + items: [ + { + name: 'test-workload-identity-1', + spiffe_id: '/test/spiffe/abb53fc8-eba6-40a9-8801-221db41f3c21', + spiffe_hint: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + labels: { + 'test-label-1': 'test-value-1', + 'test-label-2': 'test-value-2', + 'test-label-3': 'test-value-3', + }, + }, + ], + next_page_token: pageToken + '.next', + }); + }) + ); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(0); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(1); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + const [nextButton] = screen.getAllByTitle('Next page'); + await waitFor(() => expect(nextButton).toBeEnabled()); + fireEvent.click(nextButton); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(2); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '.next', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + const search = screen.getByPlaceholderText('Search...'); + await waitFor(() => expect(search).toBeEnabled()); + await userEvent.type(search, 'test-search-term'); + await userEvent.type(search, '{enter}'); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', // Search should reset to the first page + searchTerm: 'test-search-term', + sortField: 'name', + sortDir: 'ASC', + }); + }); + + it('Allows sorting', async () => { + jest.mocked(listWorkloadIdentities).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + items: [ + { + name: 'test-workload-identity-1', + spiffe_id: '/test/spiffe/abb53fc8-eba6-40a9-8801-221db41f3c21', + spiffe_hint: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + labels: { + 'test-label-1': 'test-value-1', + 'test-label-2': 'test-value-2', + 'test-label-3': 'test-value-3', + }, + }, + ], + next_page_token: pageToken, + }); + }) + ); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(0); + + render(, { wrapper: makeWrapper() }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(1); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'name', + sortDir: 'ASC', + }); + + fireEvent.click(screen.getByText('Name')); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(2); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'name', + sortDir: 'DESC', + }); + + fireEvent.click(screen.getByText('SPIFFE ID')); + + expect(listWorkloadIdentities).toHaveBeenCalledTimes(3); + expect(listWorkloadIdentities).toHaveBeenLastCalledWith({ + pageSize: 20, + pageToken: '', + searchTerm: '', + sortField: 'spiffe_id', + sortDir: 'ASC', + }); + }); +}); + +function makeWrapper( + customAcl: ReturnType = makeAcl({ + workloadIdentity: { + list: true, + create: true, + edit: true, + remove: true, + read: true, + }, + }) +) { + return ({ children }: PropsWithChildren) => { + const ctx = createTeleportContext({ + customAcl, + }); + return ( + + + + + {children} + + + + + ); + }; +} diff --git a/web/packages/teleport/src/test/helpers/workloadIdentities.ts b/web/packages/teleport/src/test/helpers/workloadIdentities.ts index a75227bc06a03..0c241f2358fb4 100644 --- a/web/packages/teleport/src/test/helpers/workloadIdentities.ts +++ b/web/packages/teleport/src/test/helpers/workloadIdentities.ts @@ -20,6 +20,7 @@ import { http, HttpResponse } from 'msw'; import cfg from 'teleport/config'; import { ListWorkloadIdentitiesResponse } from 'teleport/services/workloadIdentity/types'; +import { JsonObject } from 'teleport/types'; export const listWorkloadIdentitiesSuccess = ( mock: ListWorkloadIdentitiesResponse = { @@ -38,15 +39,14 @@ export const listWorkloadIdentitiesSuccess = ( { name: 'test-workload-identity-2', spiffe_id: '', - spiffe_hint: - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + spiffe_hint: 'This is a hint', labels: {}, }, { name: 'test-workload-identity-3', spiffe_id: '/test/spiffe/6bfd8c2d-83eb-4a6f-97ba-f8b187f08339', spiffe_hint: '', - labels: { 'test-label-1': 'test-value-1' }, + labels: { 'test-label-4': 'test-value-4' }, }, ], next_page_token: 'page-token-1', @@ -67,8 +67,9 @@ export const listWorkloadIdentitiesForever = () => export const listWorkloadIdentitiesError = ( status: number, - error: string | null = null + error: string | null = null, + fields: JsonObject = {} ) => http.get(cfg.api.workloadIdentity.list, () => { - return HttpResponse.json({ error: { message: error } }, { status }); + return HttpResponse.json({ error: { message: error }, fields }, { status }); }); From 4156c27d100787a5ce5a113a6875e4f0964c2447 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Wed, 10 Sep 2025 12:41:01 +0100 Subject: [PATCH 23/33] Add stories --- .../WorkloadIdentities.story.tsx | 167 ++++++++++++++++++ .../WorkloadIdentity.story.tsx | 27 --- 2 files changed, 167 insertions(+), 27 deletions(-) create mode 100644 web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.story.tsx delete mode 100644 web/packages/teleport/src/WorkloadIdentity/WorkloadIdentity.story.tsx diff --git a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.story.tsx b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.story.tsx new file mode 100644 index 0000000000000..257c75d4dfd45 --- /dev/null +++ b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.story.tsx @@ -0,0 +1,167 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryHistory } from 'history'; +import { MemoryRouter, Route, Router } from 'react-router'; + +import cfg from 'teleport/config'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; +import { + listWorkloadIdentitiesError, + listWorkloadIdentitiesForever, + listWorkloadIdentitiesSuccess, +} from 'teleport/test/helpers/workloadIdentities'; + +import { WorkloadIdentities } from './WorkloadIdentities'; + +const meta = { + title: 'Teleport/WorkloadIdentity', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Happy: Story = { + parameters: { + msw: { + handlers: [listWorkloadIdentitiesSuccess()], + }, + }, +}; + +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + listWorkloadIdentitiesSuccess({ + items: [], + next_page_token: null, + }), + ], + }, + }, +}; + +export const NoListPermission: Story = { + args: { hasListPermission: false }, + parameters: { + msw: { + handlers: [ + /* should never make a call */ + ], + }, + }, +}; + +export const Error: Story = { + parameters: { + msw: { + handlers: [listWorkloadIdentitiesError(500, 'something went wrong')], + }, + }, +}; + +export const OutdatedProxy: Story = { + parameters: { + msw: { + handlers: [ + listWorkloadIdentitiesError(404, 'path not found', { + proxyVersion: { + major: 18, + minor: 0, + patch: 0, + preRelease: '', + string: '18.0.0', + }, + }), + ], + }, + }, +}; + +export const UnsupportedSort: Story = { + parameters: { + msw: { + handlers: [ + listWorkloadIdentitiesError( + 400, + 'unsupported sort, with some more info' + ), + ], + }, + }, +}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [listWorkloadIdentitiesForever()], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper(props?: { hasListPermission?: boolean }) { + const { hasListPermission = true } = props ?? {}; + + const history = createMemoryHistory({ + initialEntries: [cfg.routes.workloadIdentities], + }); + + const customAcl = makeAcl({ + workloadIdentity: { + ...defaultAccess, + list: hasListPermission, + }, + }); + + const ctx = createTeleportContext({ + customAcl, + }); + + return ( + + + + + + + + + + + + ); +} diff --git a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentity.story.tsx b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentity.story.tsx deleted file mode 100644 index ecbfd9d732f75..0000000000000 --- a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentity.story.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Teleport - * Copyright (C) 2025 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { EmptyState } from './EmptyState/EmptyState'; - -export default { - title: 'Teleport/WorkloadIdentity', -}; - -export const Empty = () => { - return ; -}; From 05a0063be09ab467459dcccb79ce6ddbbd9335d6 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 12 Sep 2025 08:16:12 +0100 Subject: [PATCH 24/33] Rename nav item to "Workload Identities" Co-authored-by: Zac Bergquist --- web/packages/teleport/src/features.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 1dceb5391b44d..4e9764b5f346e 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -693,7 +693,7 @@ export class FeatureTrust implements TeleportFeature { export class FeatureWorkloadIdentity implements TeleportFeature { category = NavigationCategory.MachineWorkloadId; route = { - title: 'Workload Identity', + title: 'Workload Identities', path: cfg.routes.workloadIdentities, exact: true, component: WorkloadIdentities, From ffdb2c8d88ca7760f4d598c49cbf91c99eb7e96c Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 12 Sep 2025 08:14:41 +0100 Subject: [PATCH 25/33] Revert change to conditional render (SearchPanel) --- web/packages/shared/components/Search/SearchPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/packages/shared/components/Search/SearchPanel.tsx b/web/packages/shared/components/Search/SearchPanel.tsx index ef677d4bcfcf1..32f581b5cf326 100644 --- a/web/packages/shared/components/Search/SearchPanel.tsx +++ b/web/packages/shared/components/Search/SearchPanel.tsx @@ -85,13 +85,13 @@ export function SearchPanel({ searchValue={query} setSearchValue={updateQueryForRefetching} > - {!hideAdvancedSearch ? ( + {!hideAdvancedSearch && ( - ) : undefined} + )} From 16f489a47a0a831c4c4c0e8dc17d25b1633f5c3a Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 12 Sep 2025 08:23:48 +0100 Subject: [PATCH 26/33] Remove mono-spaced text --- .../List/WorkloadIdentitiesList.tsx | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx b/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx index 844febc8a17dc..1e4b6f6d2836f 100644 --- a/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx +++ b/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx @@ -17,13 +17,11 @@ */ import { ReactElement } from 'react'; -import styled from 'styled-components'; import { Cell, LabelCell } from 'design/DataTable/Cells'; import Table from 'design/DataTable/Table'; import { FetchingConfig, SortType } from 'design/DataTable/types'; import Flex from 'design/Flex'; -import Text from 'design/Text'; import { SearchPanel } from 'shared/components/Search/SearchPanel'; import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; @@ -85,19 +83,17 @@ export function WorkloadIdetitiesList({ return spiffe_id ? ( - - {spiffe_id - .split('/') - .reduce<(ReactElement | string)[]>((acc, cur, i) => { - if (i === 0) { - acc.push(cur); - } else { - // Add break opportunities after each slash - acc.push('/', , cur); - } - return acc; - }, [])} - + {spiffe_id + .split('/') + .reduce<(ReactElement | string)[]>((acc, cur, i) => { + if (i === 0) { + acc.push(cur); + } else { + // Add break opportunities after each slash + acc.push('/', , cur); + } + return acc; + }, [])} @@ -130,10 +126,6 @@ export function WorkloadIdetitiesList({ ); } -const MonoText = styled(Text)` - font-family: ${({ theme }) => theme.fonts.mono}; -`; - function valueOrEmpty(value: string | null | undefined, empty = '-') { return value || empty; } From 5e8c224f34ce84757ca557628ff3184a0587d9d4 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 12 Sep 2025 09:39:49 +0100 Subject: [PATCH 27/33] Suggested code refinements --- lib/cache/workload_identity.go | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index e36a9b48cbb3f..0468a995c69f2 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -84,17 +84,17 @@ func (c *Cache) ListWorkloadIdentities( index := workloadIdentityNameIndex keyFn := keyForWorkloadIdentityNameIndex isDesc := options.GetSortDesc() - if options.GetSortField() != "" { - switch options.GetSortField() { - case "name": - index = workloadIdentityNameIndex - keyFn = keyForWorkloadIdentityNameIndex - case "spiffe_id": - index = workloadIdentitySpiffeIDIndex - keyFn = keyForWorkloadIdentitySpiffeIDIndex - default: - return nil, "", trace.BadParameter("unsupported sort %q but expected name or spiffe_id", options.GetSortField()) - } + switch options.GetSortField() { + case "name": + index = workloadIdentityNameIndex + keyFn = keyForWorkloadIdentityNameIndex + case "spiffe_id": + index = workloadIdentitySpiffeIDIndex + keyFn = keyForWorkloadIdentitySpiffeIDIndex + case "": + // default ordering as defined above + default: + return nil, "", trace.BadParameter("unsupported sort %q but expected name or spiffe_id", options.GetSortField()) } lister := genericLister[*workloadidentityv1pb.WorkloadIdentity, workloadIdentityIndex]{ @@ -108,9 +108,7 @@ func (c *Cache) ListWorkloadIdentities( filter: func(b *workloadidentityv1pb.WorkloadIdentity) bool { return services.MatchWorkloadIdentity(b, options.GetFilterSearchTerm()) }, - nextToken: func(t *workloadidentityv1pb.WorkloadIdentity) string { - return keyFn(t) - }, + nextToken: keyFn, } out, next, err := lister.list(ctx, pageSize, nextToken) return out, next, trace.Wrap(err) From 842b9cb9dd128269f1ff4e66ec867114916f2a6d Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 12 Sep 2025 10:30:53 +0100 Subject: [PATCH 28/33] Join spiffe_id page keys with a pipe --- lib/cache/workload_identity.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index e36a9b48cbb3f..76fd19a2ef55b 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -137,5 +137,6 @@ func keyForWorkloadIdentityNameIndex(r *workloadidentityv1pb.WorkloadIdentity) s func keyForWorkloadIdentitySpiffeIDIndex(r *workloadidentityv1pb.WorkloadIdentity) string { // SPIFFE IDs may not be unique, so append the resource name - return r.GetSpec().GetSpiffe().GetId() + "/" + r.GetMetadata().GetName() + // Join using a "|" to avoid the "a/b" + "/" + "c" vs "a" + "/" + "b/c" problem. + return r.GetSpec().GetSpiffe().GetId() + "|" + r.GetMetadata().GetName() } From 0a417d2747d7c1d15e6713f90f874efb528b586c Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 12 Sep 2025 10:30:53 +0100 Subject: [PATCH 29/33] Join spiffe_id page keys with a pipe --- lib/cache/workload_identity.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index 0468a995c69f2..d1a155cb75157 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -135,5 +135,6 @@ func keyForWorkloadIdentityNameIndex(r *workloadidentityv1pb.WorkloadIdentity) s func keyForWorkloadIdentitySpiffeIDIndex(r *workloadidentityv1pb.WorkloadIdentity) string { // SPIFFE IDs may not be unique, so append the resource name - return r.GetSpec().GetSpiffe().GetId() + "/" + r.GetMetadata().GetName() + // Join using a "|" to avoid the "a/b" + "/" + "c" vs "a" + "/" + "b/c" problem. + return r.GetSpec().GetSpiffe().GetId() + "|" + r.GetMetadata().GetName() } From 4da26deacb18802b50868f0b05b8ece1a2ae8ecb Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Fri, 12 Sep 2025 10:34:12 +0100 Subject: [PATCH 30/33] Revert "Join spiffe_id page keys with a pipe" This reverts commit 842b9cb9dd128269f1ff4e66ec867114916f2a6d. --- lib/cache/workload_identity.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index 76fd19a2ef55b..e36a9b48cbb3f 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -137,6 +137,5 @@ func keyForWorkloadIdentityNameIndex(r *workloadidentityv1pb.WorkloadIdentity) s func keyForWorkloadIdentitySpiffeIDIndex(r *workloadidentityv1pb.WorkloadIdentity) string { // SPIFFE IDs may not be unique, so append the resource name - // Join using a "|" to avoid the "a/b" + "/" + "c" vs "a" + "/" + "b/c" problem. - return r.GetSpec().GetSpiffe().GetId() + "|" + r.GetMetadata().GetName() + return r.GetSpec().GetSpiffe().GetId() + "/" + r.GetMetadata().GetName() } From 9495c8a6059c274777e44e6a6b65e393fd72975f Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 15 Sep 2025 10:21:21 +0100 Subject: [PATCH 31/33] Base32 hex encode id for cache key --- lib/cache/workload_identity.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/cache/workload_identity.go b/lib/cache/workload_identity.go index d1a155cb75157..34bfda3231043 100644 --- a/lib/cache/workload_identity.go +++ b/lib/cache/workload_identity.go @@ -19,6 +19,7 @@ package cache import ( "context" + "encoding/base32" "github.com/gravitational/trace" "google.golang.org/protobuf/proto" @@ -134,7 +135,11 @@ func keyForWorkloadIdentityNameIndex(r *workloadidentityv1pb.WorkloadIdentity) s } func keyForWorkloadIdentitySpiffeIDIndex(r *workloadidentityv1pb.WorkloadIdentity) string { + // Encode the id avoid; "a/b" + "/" + "c" vs. "a" + "/" + "b/c" + // Base32 hex maintains original ordering. + encodedId := unpaddedBase32hex.EncodeToString([]byte(r.GetSpec().GetSpiffe().GetId())) // SPIFFE IDs may not be unique, so append the resource name - // Join using a "|" to avoid the "a/b" + "/" + "c" vs "a" + "/" + "b/c" problem. - return r.GetSpec().GetSpiffe().GetId() + "|" + r.GetMetadata().GetName() + return encodedId + "/" + r.GetMetadata().GetName() } + +var unpaddedBase32hex = base32.HexEncoding.WithPadding(base32.NoPadding) From 1265cedf254064142525fbf21275d85b63752fd0 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 15 Sep 2025 11:15:04 +0100 Subject: [PATCH 32/33] Add missing useCallback dep --- .../teleport/src/WorkloadIdentity/WorkloadIdentities.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx index d5a9376f1506a..abb177a7590ec 100644 --- a/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx +++ b/web/packages/teleport/src/WorkloadIdentity/WorkloadIdentities.tsx @@ -119,7 +119,7 @@ export function WorkloadIdentities() { prevPageTokens: prevTokens, } ); - }, [history, location.pathname, location.search]); + }, [history, location.pathname, location.search, prevPageTokens]); const sortType: SortType = { fieldName: sortField, From b3d80cc3bbcbe4a1936c90daf0edc2af4f1e4865 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Mon, 15 Sep 2025 12:42:25 +0100 Subject: [PATCH 33/33] Fix word break opportunities on Firefox --- .../List/WorkloadIdentitiesList.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx b/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx index 1e4b6f6d2836f..4bd601b064a34 100644 --- a/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx +++ b/web/packages/teleport/src/WorkloadIdentity/List/WorkloadIdentitiesList.tsx @@ -22,6 +22,7 @@ import { Cell, LabelCell } from 'design/DataTable/Cells'; import Table from 'design/DataTable/Table'; import { FetchingConfig, SortType } from 'design/DataTable/types'; import Flex from 'design/Flex'; +import Text from 'design/Text'; import { SearchPanel } from 'shared/components/Search/SearchPanel'; import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; @@ -83,17 +84,19 @@ export function WorkloadIdetitiesList({ return spiffe_id ? ( - {spiffe_id - .split('/') - .reduce<(ReactElement | string)[]>((acc, cur, i) => { - if (i === 0) { - acc.push(cur); - } else { - // Add break opportunities after each slash - acc.push('/', , cur); - } - return acc; - }, [])} + + {spiffe_id + .split('/') + .reduce<(ReactElement | string)[]>((acc, cur, i) => { + if (i === 0) { + acc.push(cur); + } else { + // Add break opportunities after each slash + acc.push('/', , cur); + } + return acc; + }, [])} +