WIP: address feedback

Signed-off-by: Jessica He <jhe@redhat.com>
This commit is contained in:
Jessica He 2024-08-27 10:24:24 -04:00
parent d33c1c66ce
commit 80303eb9ea
19 changed files with 1829 additions and 29670 deletions

1
.github/CODEOWNERS vendored
View File

@ -32,3 +32,4 @@ yarn.lock @backstage/community-plugins
/workspaces/tech-insights @backstage/community-plugins-maintainers @xantier
/workspaces/report-portal @backstage/community-plugins-maintainers @yashoswalyo
/workspaces/redhat-resource-optimization @backstage/community-plugins-maintainers
/workspaces/pingidentity @backstage/community-plugins-maintainers @jessicajhee

View File

@ -38,6 +38,8 @@ catalog:
envId: ${PING_IDENTITY_ENV_ID}
clientId: ${PING_IDENTITY_CLIENT_ID}
clientSecret: ${PING_IDENTITY_CLIENT_SECRET}
userQuerySize: 2
groupQuerySize: 2
schedule: # Mandatory; same options as in TaskScheduleDefinition
# supports cron, ISO duration, "human duration" as used in code
frequency: { seconds: 30 } # Customize this to fit your needs

View File

@ -1,3 +1,3 @@
{
"version": "1.29.0"
"version": "1.29.2"
}

View File

@ -2,12 +2,9 @@ apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: pingidentity
description: An example of a Backstage application.
# Example for optional annotations
# annotations:
# github.com/project-slug: backstage/backstage
# backstage.io/techdocs-ref: dir:.
title: '@backstage-community/pingidentity'
description: An Backstage plugin that ingests users and groups from Ping Identity into the catalog.
spec:
type: website
owner: john@example.com
type: backstage-backend-plugin-module
owner: jessicajhee
lifecycle: experimental

File diff suppressed because it is too large Load Diff

View File

@ -22,10 +22,6 @@ export interface Config {
* The envId where the application is located
*/
envId: string;
/**
* Schedule configuration for refresh tasks.
*/
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
/**
* PingIdentityClientCredentials
*/
@ -38,6 +34,26 @@ export interface Config {
* @visibility secret
*/
clientSecret: string;
/**
* Schedule configuration for refresh tasks.
*/
schedule?: SchedulerServiceTaskScheduleDefinitionConfig;
/**
* The number of users to query at a time.
* @defaultValue 100
* @remarks
* This is a performance optimization to avoid querying too many users at once.
* @see https://apidocs.pingidentity.com/pingone/platform/v1/api/#paging-ordering-and-filtering-collections
*/
userQuerySize?: number;
/**
* The number of groups to query at a time.
* @defaultValue 100
* @remarks
* This is a performance optimization to avoid querying too many groups at once.
* @see https://apidocs.pingidentity.com/pingone/platform/v1/api/#paging-ordering-and-filtering-collections
*/
groupQuerySize?: number;
}
};
};

View File

@ -145,7 +145,7 @@ describe('PingIdentityClient', () => {
}),
);
const users = await client.getUsers();
const users = await client.getUsers(10);
expect(users).toEqual([
{
@ -200,7 +200,7 @@ describe('PingIdentityClient', () => {
}),
);
const groups = await client.getGroups();
const groups = await client.getGroups(10);
expect(groups).toEqual([
{
@ -231,7 +231,7 @@ describe('PingIdentityClient', () => {
size: 1,
_embedded: {
groupMemberships: [
{ name: 'Parent Group' },
{ id: 'ParentGroup' },
],
},
}),
@ -239,9 +239,9 @@ describe('PingIdentityClient', () => {
}),
);
const parentGroup = await client.getParentGroup('group1');
const parentGroup = await client.getParentGroupId('group1');
expect(parentGroup).toBe('Parent Group');
expect(parentGroup).toBe('ParentGroup');
});
it('should return undefined if no parent group exists', async () => {
@ -265,7 +265,7 @@ describe('PingIdentityClient', () => {
}),
);
const parentGroup = await client.getParentGroup('group1');
const parentGroup = await client.getParentGroupId('group1');
expect(parentGroup).toBeUndefined();
});

View File

@ -1,6 +1,7 @@
import fetch, { Response } from 'node-fetch';
import { PingIdentityProviderConfig } from './config';
import { PingIdentityGroup, PingIdentityUser } from './types';
import { PingIdentityGroup, PingIdentityResponse, PingIdentityUser } from './types';
import { PING_IDENTITY_DEFAULT_ENTITY_QUERY_SIZE } from './constants';
class PingIdentityClient {
private tokenCredential: string | null = null;
@ -19,37 +20,59 @@ class PingIdentityClient {
/**
* Gets a list of all users fetched from Ping Identity API
*
* @param querySize - the number of users to query at a time
*
* @returns a list of all users fetched from Ping Identity API
*/
async getUsers(): Promise<PingIdentityUser[]> {
const response = await this.requestApi('users');
const data = await response.json();
return data._embedded.users;
async getUsers(querySize: number = PING_IDENTITY_DEFAULT_ENTITY_QUERY_SIZE): Promise<PingIdentityUser[]> {
const allUsers: PingIdentityUser[] = [];
let nextUrl: string | undefined = `users?limit=${querySize}`;
while (nextUrl) {
const url = nextUrl.startsWith('http') ? nextUrl : `${this.config.apiPath}/environments/${this.config.envId}/${nextUrl}`;
const response = await this.requestApi(url, true);
const data: PingIdentityResponse = await response.json() as PingIdentityResponse;
allUsers.push(...(data._embedded.users as PingIdentityUser[]));
nextUrl = data._links?.next?.href || undefined;
}
return allUsers;
}
/**
* Gets a list of all groups fetched from Ping Identity API
*
* @param querySize - the number of groups to query at a time
*
* @returns a list of all groups fetched from Ping Identity API
*/
async getGroups(): Promise<PingIdentityGroup[]> {
const response = await this.requestApi('groups');
const data = await response.json();
return data._embedded.groups;
async getGroups(querySize: number = PING_IDENTITY_DEFAULT_ENTITY_QUERY_SIZE): Promise<PingIdentityGroup[]> {
const allGroups: PingIdentityGroup[] = [];
let nextUrl: string | undefined = `groups?limit=${querySize}`;
while (nextUrl) {
const url = nextUrl.startsWith('http') ? nextUrl : `${this.config.apiPath}/environments/${this.config.envId}/${nextUrl}`;
const response = await this.requestApi(url, true);
const data: PingIdentityResponse = await response.json() as PingIdentityResponse;
allGroups.push(...(data._embedded.groups as PingIdentityGroup[]));
nextUrl = data._links?.next?.href || undefined;
}
return allGroups;
}
/**
* Gets the parent group of a given group, returns undefined if there is no parent group
* Gets the parent group ID of a given group, returns undefined if there is no parent group
*
* @param groupId the group ID of a given group
*
* @returns the parent group of a given group, undefined if there is no parent group
* @returns the parent group ID of a given group, undefined if there is no parent group
*/
async getParentGroup(groupId: string): Promise<string | undefined> {
async getParentGroupId(groupId: string): Promise<string | undefined> {
const response = await this.requestApi(`groups/${groupId}/memberOfGroups`);
const data = await response.json();
return data.size > 0
? data._embedded.groupMemberships[0].name
? data._embedded.groupMemberships[0].id
: undefined;
}
@ -70,11 +93,11 @@ class PingIdentityClient {
* Makes a Ping Identity API request to the configured environment
*
* @param query the query to be made
*
* @param isFullUrl Optional - true if the given query is the full request url
* @returns the response to the given API call
*/
async requestApi(query: string): Promise<Response> {
const url = `${this.config.apiPath}/environments/${this.config.envId}/${query}`;
async requestApi(query: string, isFullUrl?: boolean): Promise<Response> {
const url = isFullUrl ? query : `${this.config.apiPath}/environments/${this.config.envId}/${query}`;
let accessToken = await this.getAccessToken();
let response = await this.makeRequest(url, accessToken);

View File

@ -23,6 +23,8 @@ describe('readProviderConfigs', () => {
minutes: 3,
},
},
userQuerySize: 100,
groupQuerySize: 200,
},
},
},
@ -47,6 +49,8 @@ describe('readProviderConfigs', () => {
minutes: 3,
},
},
userQuerySize: 100,
groupQuerySize: 200,
},
];
expect(actual).toEqual(expected);

View File

@ -46,6 +46,22 @@ export type PingIdentityProviderConfig = {
* Schedule configuration for refresh tasks.
*/
schedule?: SchedulerServiceTaskScheduleDefinition;
/**
* The number of users to query at a time.
* @defaultValue 100
* @remarks
* This is a performance optimization to avoid querying too many users at once.
* @see https://apidocs.pingidentity.com/pingone/platform/v1/api/#paging-ordering-and-filtering-collections
*/
userQuerySize?: number;
/**
* The number of groups to query at a time.
* @defaultValue 100
* @remarks
* This is a performance optimization to avoid querying too many groups at once.
* @see https://apidocs.pingidentity.com/pingone/platform/v1/api/#paging-ordering-and-filtering-collections
*/
groupQuerySize?: number;
};
const readProviderConfig = (
@ -57,6 +73,10 @@ const readProviderConfig = (
const envId = providerConfigInstance.getString('envId');
const clientId = providerConfigInstance.getOptionalString('clientId');
const clientSecret = providerConfigInstance.getOptionalString('clientSecret');
const userQuerySize =
providerConfigInstance.getOptionalNumber('userQuerySize');
const groupQuerySize =
providerConfigInstance.getOptionalNumber('groupQuerySize');
if (clientId && !clientSecret) {
throw new Error(`clientSecret must be provided when clientId is defined.`);
@ -80,6 +100,8 @@ const readProviderConfig = (
clientId,
clientSecret,
schedule,
userQuerySize,
groupQuerySize,
};
};

View File

@ -1 +1,2 @@
export const PING_IDENTITY_ID_ANNOTATION = 'pingidentity.org/id';
export const PING_IDENTITY_ID_ANNOTATION = 'pingidentity.org/id';
export const PING_IDENTITY_DEFAULT_ENTITY_QUERY_SIZE = 100;

View File

@ -11,8 +11,8 @@ describe('defaultTransformers', () => {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
annotations: { 'graph.microsoft.com/group-id': 'foo' },
name: 'bar',
annotations: { 'pingidentity.org/id': 'foo' },
name: 'group one',
},
spec: {
children: [],
@ -26,11 +26,11 @@ describe('defaultTransformers', () => {
href: ''
}
},
id: 'bar',
id: 'group1',
environment: {
id: ''
},
name: 'bar',
name: 'group one',
description: '',
directMemberCounts: {
users: 0
@ -39,8 +39,20 @@ describe('defaultTransformers', () => {
updatedAt: ''
}
const result = await defaultGroupTransformer(group, pingIdentityGroup, 'envId');
// should not make any transformations
expect(result).toEqual(group);
// should normalize illegal characters in group name
expect(result).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
metadata: {
annotations: { 'pingidentity.org/id': 'foo' },
name: 'group_one',
},
spec: {
children: [],
profile: { displayName: 'BAR' },
type: 'team',
},
});
});
it('tests defaultUserTransformer', async () => {
@ -49,7 +61,7 @@ describe('defaultTransformers', () => {
kind: 'User',
metadata: {
annotations: {
'graph.microsoft.com/user-id': 'foo',
'pingidentity.org/id': 'foo',
},
name: 'test~user@example.com',
},
@ -126,7 +138,7 @@ describe('defaultTransformers', () => {
kind: 'User',
metadata: {
annotations: {
'graph.microsoft.com/user-id': 'foo',
'pingidentity.org/id': 'foo',
},
name: 'test_user_example.com',
},

View File

@ -10,7 +10,10 @@ import { GroupTransformer, UserTransformer } from './types';
export const defaultGroupTransformer: GroupTransformer = async (
entity,
_envId,
) => entity;
) => {
entity.metadata.name = entity.metadata.name.replace(/[^a-zA-Z0-9_\-\.]/g, '_');
return entity;
};
/**
* The default user transformer if none is provided

View File

@ -173,7 +173,7 @@ describe('PingIdentity readPingIdentity', () => {
expect(result.groups).toHaveLength(1);
expect(result.users).toHaveLength(2);
expect(result.groups[0].metadata.name).toBe('Group One');
expect(result.groups[0].metadata.name).toBe('Group_One');
expect(result.users[0].metadata.name).toBe('user1');
expect(result.users[1].metadata.name).toBe('user2');
});
@ -209,14 +209,14 @@ describe('PingIdentity readPingIdentity', () => {
]);
mockClient.getUsers.mockResolvedValue([exampleUser1]);
mockClient.getUsersInGroup.mockResolvedValue(['user1']);
mockClient.getParentGroup.mockResolvedValue(undefined);
mockClient.getParentGroupId.mockResolvedValue(undefined);
const result = await readPingIdentity(mockClient);
expect(result.groups).toHaveLength(1);
expect(result.users).toHaveLength(1);
expect(result.users[0].spec.memberOf).toHaveLength(1);
expect(result.users[0].spec.memberOf).toStrictEqual(['Group One']);
expect(result.users[0].spec.memberOf).toStrictEqual(['Group_One']);
});
it('should handle nested groups', async () => {
@ -256,7 +256,7 @@ describe('PingIdentity readPingIdentity', () => {
]);
mockClient.getUsers.mockResolvedValue([]);
mockClient.getUsersInGroup.mockResolvedValue([]);
mockClient.getParentGroup.mockImplementation(async (groupId) => {
mockClient.getParentGroupId.mockImplementation(async (groupId) => {
if (groupId === 'group2') return 'group1';
return undefined;
});
@ -264,9 +264,9 @@ describe('PingIdentity readPingIdentity', () => {
const result = await readPingIdentity(mockClient);
expect(result.groups).toHaveLength(2);
const groupTwo = result.groups.find(g => g.metadata.name === 'Group Two');
const groupTwo = result.groups.find(g => g.metadata.name === 'Group_Two');
expect(groupTwo?.spec.parent).toBe('group1');
expect(groupTwo?.spec.parent).toBe('Group_One');
});
it('should handle errors in fetching users or groups', async () => {

View File

@ -57,6 +57,7 @@ const getEntityLocation = (
* @param client - The `PingIdentityClient`
* @param groups - A list of all groups
* @param groupMembersMap - Maps group ID to all user IDs that belong in that group
* @param userQuerySize - the number of users to query at a time
* @param userTransformer - Optional user transformer method
*
* @returns a parsed list of all users fetched from Ping Identity of type `UserEntity`
@ -65,10 +66,11 @@ const parsePingIdentityUsers = async (
client: PingIdentityClient,
groups: GroupEntity[],
groupMembersMap: Map<string, Set<string>>,
userQuerySize?: number,
userTransformer?: UserTransformer
): Promise<UserEntity[]> => {
const transformer = userTransformer ?? defaultUserTransformer;
const pingIdentityUsers: PingIdentityUser[] = await client.getUsers();
const pingIdentityUsers: PingIdentityUser[] = await client.getUsers(userQuerySize);
const transformedUsers: (UserEntity | undefined)[] = await Promise.all(pingIdentityUsers.map(async (user: any) => {
const userLocation = getEntityLocation(client.getConfig(), 'users', user.id);
return await transformer({
@ -104,7 +106,9 @@ const parsePingIdentityUsers = async (
* Returns a parsed list of all groups in the Ping Identity environment
*
* @param client - The `PingIdentityClient`
* @param groupMembersMap - Maps group ID to all user IDs that belong in that group
* @param groupMembersMap - Maps group ID to all user IDs that belong in that
* @param parentGroupMap - Maps group ID to its parent group ID
* @param groupQuerySize - the number of groups to query at a time
* @param groupTransformer - Optional group transformer method
*
* @returns a parsed list of all groups fetched from Ping Identity of type `GroupEntity`
@ -112,14 +116,20 @@ const parsePingIdentityUsers = async (
const parsePingIdentityGroups = async (
client: PingIdentityClient,
groupMembersMap: Map<string, Set<string>>,
parentGroupMap: Map<string, string>,
groupQuerySize?: number,
groupTransformer?: GroupTransformer
): Promise<GroupEntity[]> => {
const transformer = groupTransformer ?? defaultGroupTransformer;
const pingIdentityGroups: PingIdentityGroup[] = await client.getGroups();
const pingIdentityGroups: PingIdentityGroup[] = await client.getGroups(groupQuerySize);
const transformedGroups: (GroupEntity | undefined)[] = await Promise.all(pingIdentityGroups.map(async (group: any) => {
const groupLocation = getEntityLocation(client.getConfig(), 'groups', group.id);
// add users in group to group membership map
groupMembersMap.set(group.id, new Set(await client.getUsersInGroup(group.id)));
// add parent group relationship to map
const parentGroupId = await client.getParentGroupId(group.id);
if (parentGroupId) parentGroupMap.set(group.id, parentGroupId);
return await transformer({
apiVersion: 'backstage.io/v1beta1',
kind: 'Group',
@ -138,8 +148,7 @@ const parsePingIdentityGroups = async (
displayName: group.name!,
},
children: [],
parent: await client.getParentGroup(group.id),
members: [],
parent: undefined, // will be updated later
}
}, group, client.getConfig().envId);
}));
@ -160,6 +169,8 @@ const parsePingIdentityGroups = async (
export const readPingIdentity = async (
client: PingIdentityClient,
options?: {
userQuerySize?: number;
groupQuerySize?: number;
userTransformer?: UserTransformer;
groupTransformer?: GroupTransformer;
},
@ -168,7 +179,23 @@ export const readPingIdentity = async (
groups: GroupEntity[];
}> => {
const groupMembersMap = new Map<string, Set<string>>();
const groups: GroupEntity[] = await parsePingIdentityGroups(client, groupMembersMap, options?.groupTransformer);
const users: UserEntity[] = await parsePingIdentityUsers(client, groups, groupMembersMap, options?.userTransformer);
const parentGroupMap = new Map<string, string>();
const groups: GroupEntity[] = await parsePingIdentityGroups(client, groupMembersMap, parentGroupMap, options?.userQuerySize, options?.groupTransformer);
// update parent/child group relationship
const groupsMap = new Map<string, GroupEntity>();
groups.forEach(group => {
const groupId = group.metadata.annotations![PING_IDENTITY_ID_ANNOTATION];
groupsMap.set(groupId, group);
});
groups.forEach((group) => {
const parentGroupId = parentGroupMap.get(group.metadata.annotations![PING_IDENTITY_ID_ANNOTATION]);
if (parentGroupId) {
const parentGroup = groupsMap.get(parentGroupId);
group.spec.parent = parentGroup?.metadata.name;
}
});
const users: UserEntity[] = await parsePingIdentityUsers(client, groups, groupMembersMap, options?.groupQuerySize, options?.userTransformer);
return { users, groups };
};

View File

@ -35,6 +35,31 @@ export type GroupTransformer = (
envId: string,
) => Promise<GroupEntity | undefined>;
/**
* Ping Identity API response type
*
* @public
*/
export interface PingIdentityResponse {
_links: {
self: {
href: string;
};
prev?: {
href: string;
};
next?: {
href: string;
};
};
_embedded: {
users?: PingIdentityUser [];
groups?: PingIdentityGroup[];
};
count?: number; // total count of items in the collection
size?: number; // count of the current page of results
}
/**
* Ping Identity user type
*

View File

@ -143,6 +143,8 @@ export class PingIdentityEntityProvider implements EntityProvider {
const client = new PingIdentityClient(provider);
const { users, groups } = await readPingIdentity(client,
{
userQuerySize: this.options.provider.userQuerySize,
groupQuerySize: this.options.provider.groupQuerySize,
userTransformer: this.options.userTransformer,
groupTransformer: this.options.groupTransformer,
}

File diff suppressed because it is too large Load Diff