get group members via graphQL

The new `getGroupMembers` method replaces the existing `getUserMemberships` on
`GitLabClient`.

Signed-off-by: Jamie Klassen <jklassen@vmware.com>
This commit is contained in:
Jamie Klassen
2023-03-14 16:11:29 -04:00
parent 3984e7b44d
commit 7b1b7bfdb7
7 changed files with 132 additions and 86 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-catalog-backend-module-gitlab': patch
---
The gitlab org data integration now makes use of the GraphQL API to determine
the relationships between imported User and Group entities, effectively making
this integration usable without an administrator account's Personal Access
Token.
+13 -12
View File
@@ -2,15 +2,14 @@
id: org
title: GitLab Organizational Data
sidebar_label: Org Data
description: Importing users and groups from a GitLab organization into Backstage
description: Importing users and groups from GitLab into Backstage
---
The Backstage catalog can be set up to ingest organizational data - users and
teams - directly from an organization in GitLab. The result
is a hierarchy of
The Backstage catalog can be set up to ingest organizational data -- users and
groups -- directly from GitLab. The result is a hierarchy of
[`User`](../../features/software-catalog/descriptor-format.md#kind-user) and
[`Group`](../../features/software-catalog/descriptor-format.md#kind-group) kind
entities that mirror your org setup.
[`Group`](../../features/software-catalog/descriptor-format.md#kind-group)
entities that mirrors your org setup.
```yaml
integrations:
@@ -19,10 +18,11 @@ integrations:
token: ${GITLAB_TOKEN}
```
This will query all users and groups from your gitlab installation. Depending on the size
of the Gitlab Instance, this can take some time and resources.
This will query all users and groups from your GitLab instance. Depending on the
amount of data, this can take significant time and resources.
The token that is used for the Organization Integration, has to be an Admin Personal Access Token (PAT).
The token used must have the `read_api` scope, and the Users and Groups fetched
will be those visible to the account which provisioned the token.
```yaml
catalog:
@@ -35,6 +35,7 @@ catalog:
groupPattern: '[\s\S]*' # Optional. Filters found groups based on provided pattern. Defaults to `[\s\S]*`, which means to not filter anything
```
When the `group` parameter is provided, the corresponding path prefix will be stripped out from each matching group
when computing the unique entity name. e.g. If `group` is `org/teams`, the name for `org/teams/avengers/gotg` will
be `avengers-gotg`.
When the `group` parameter is provided, the corresponding path prefix will be
stripped out from each matching group when computing the unique entity name.
e.g. If `group` is `org/teams`, the name for `org/teams/avengers/gotg` will be
`avengers-gotg`.
@@ -18,10 +18,10 @@ import { ConfigReader } from '@backstage/config';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { readGitLabIntegrationConfig } from '@backstage/integration';
import { getVoidLogger } from '@backstage/backend-common';
import { rest } from 'msw';
import { graphql, rest } from 'msw';
import { setupServer, SetupServerApi } from 'msw/node';
import { GitLabClient, paginated } from './client';
import { GitLabUser } from './types';
import { GitLabGroup, GitLabUser } from './types';
const server = setupServer();
setupRequestMockHandlers(server);
@@ -31,6 +31,7 @@ const MOCK_CONFIG = readGitLabIntegrationConfig(
host: 'example.com',
token: 'test-token',
apiBaseUrl: 'https://example.com/api/v4',
baseUrl: 'https://example.com',
}),
);
const FAKE_PAGED_ENDPOINT = `/some-endpoint`;
@@ -338,6 +339,7 @@ describe('GitLabClient', () => {
},
]);
});
it('listGroups gets all groups in the instance', async () => {
server.use(
rest.get(`${MOCK_CONFIG.apiBaseUrl}/groups`, (_, res, ctx) =>
@@ -395,6 +397,32 @@ describe('GitLabClient', () => {
},
]);
});
it('getGroupMembers gets member IDs', async () => {
server.use(
graphql
.link(`${MOCK_CONFIG.baseUrl}/api/graphql`)
.operation((_, res, ctx) =>
res(
ctx.data({
group: {
groupMembers: {
nodes: [{ user: { id: 'gid://gitlab/User/1' } }],
},
},
}),
),
),
);
const client = new GitLabClient({
config: MOCK_CONFIG,
logger: getVoidLogger(),
});
const members = await client.getGroupMembers('group1');
expect(members).toEqual([1]);
});
});
describe('paginated', () => {
@@ -20,7 +20,7 @@ import {
GitLabIntegrationConfig,
} from '@backstage/integration';
import { Logger } from 'winston';
import { GitLabGroup, GitLabMembership, GitLabUser } from './types';
import { GitLabGroup, GitLabGroupMembersResponse, GitLabUser } from './types';
export type CommonListOptions = {
[key: string]: string | number | boolean | undefined;
@@ -91,30 +91,37 @@ export class GitLabClient {
return this.pagedRequest(`/groups`, options);
}
async getUserMemberships(userId: number): Promise<GitLabMembership[]> {
const endpoint: string = `/users/${encodeURIComponent(userId)}/memberships`;
const request = new URL(`${this.config.apiBaseUrl}${endpoint}`);
request.searchParams.append('per_page', '100');
async getGroupMembers(groupPath: string): Promise<number[]> {
const response: GitLabGroupMembersResponse = await fetch(
`${this.config.baseUrl}/api/graphql`,
{
method: 'POST',
headers: {
...getGitLabRequestOptions(this.config).headers,
['Content-Type']: 'application/json',
},
body: JSON.stringify({
variables: { group: groupPath },
query: `query($group: ID!) {
group(fullPath: $group) {
groupMembers(first: 10, relations: [DIRECT]) {
nodes {
user {
id
}
}
}
}
}`,
}),
},
).then(r => r.json());
this.logger.debug(`got GraphQL response: ${JSON.stringify(response)}`);
const response = await fetch(request.toString(), {
headers: getGitLabRequestOptions(this.config).headers,
method: 'GET',
});
if (!response.ok) {
if (response.status >= 500) {
this.logger.debug(
`Unexpected response when fetching ${request.toString()}. Expected 200 but got ${
response.status
} - ${response.statusText}`,
);
}
return [];
}
return response.json().then(items => {
return items as GitLabMembership[];
});
return response.data.group.groupMembers.nodes.map(
(node: { user: { id: string } }) =>
Number(node.user.id.replace(/^gid:\/\/gitlab\/User\//, '')),
);
}
/**
@@ -50,11 +50,19 @@ export type GitLabGroup = {
parent_id?: number;
};
export type GitLabMembership = {
source_id: number;
source_name: string;
source_type: string;
access_level: number;
export type GitLabGroupMembersResponse = {
errors: { message: string }[];
data: {
group: {
groupMembers: {
nodes: { user: { id: string } }[];
pageInfo: {
endCursor: string;
hasNextPage: boolean;
};
};
};
};
};
export type GitlabProviderConfig = {
@@ -23,7 +23,7 @@ import {
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { EntityProviderConnection } from '@backstage/plugin-catalog-node';
import { rest } from 'msw';
import { graphql, rest } from 'msw';
import { setupServer } from 'msw/node';
import { GitlabOrgDiscoveryEntityProvider } from './GitlabOrgDiscoveryEntityProvider';
@@ -318,20 +318,22 @@ describe('GitlabOrgDiscoveryEntityProvider', () => {
];
return res(ctx.json(response));
}),
rest.get(
`https://api.gitlab.example/api/v4/users/1/memberships`,
(_req, res, ctx) => {
const response = [
{
source_id: 2,
source_name: 'Group 2',
source_type: 'Namespace',
access_level: 50,
},
];
return res(ctx.json(response));
},
),
graphql
.link('https://test-gitlab/api/graphql')
.operation(async (req, res, ctx) =>
res(
ctx.data({
group: {
groupMembers: {
nodes:
req.variables.group === 'group1/group2'
? [{ user: { id: 'gid://gitlab/User/1' } }]
: [],
},
},
}),
),
),
);
await provider.connect(entityProviderConnection);
@@ -185,7 +185,7 @@ export class GitlabOrgDiscoveryEntityProvider implements EntityProvider {
},
);
const idMappedGroup: { [groupId: number]: GitLabGroup } = {};
const idMappedUser: { [userId: number]: GitLabUser } = {};
const res: Result = {
scanned: 0,
@@ -197,6 +197,21 @@ export class GitlabOrgDiscoveryEntityProvider implements EntityProvider {
matches: [],
};
for await (const user of users) {
if (!this.config.userPattern.test(user.email ?? user.username ?? '')) {
continue;
}
res.scanned++;
if (user.state !== 'active') {
continue;
}
idMappedUser[user.id] = user;
res.matches.push(user);
}
for await (const group of groups) {
if (!this.config.groupPattern.test(group.full_path ?? '')) {
continue;
@@ -212,35 +227,12 @@ export class GitlabOrgDiscoveryEntityProvider implements EntityProvider {
groupRes.scanned++;
groupRes.matches.push(group);
idMappedGroup[group.id] = group;
}
for await (const user of users) {
if (!this.config.userPattern.test(user.email ?? user.username ?? '')) {
continue;
}
res.scanned++;
if (user.state !== 'active') {
continue;
}
const memberships = await client.getUserMemberships(user.id);
const userGroups: GitLabGroup[] = [];
for (const i of memberships) {
if (
i.source_type === 'Namespace' &&
idMappedGroup.hasOwnProperty(i.source_id)
) {
userGroups.push(idMappedGroup[i.source_id]);
for (const id of await client.getGroupMembers(group.full_path)) {
const user = idMappedUser[id];
if (user) {
user.groups = (user.groups ?? []).concat(group);
}
}
user.groups = userGroups;
res.matches.push(user);
}
const groupsWithUsers = groupRes.matches.filter(group => {