Merge pull request #32133 from JessicaJHee/sync-gl-id

feat(catalog): add gitlab user ID in user entity
This commit is contained in:
Fredrik Adelöw
2026-01-21 11:15:15 +01:00
committed by GitHub
13 changed files with 161 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-github-provider': minor
---
Added the `userIdMatchingUserEntityAnnotation` sign-in resolver that matches users by their GitHub user ID.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-auth-backend-module-gitlab-provider': minor
'@backstage/plugin-catalog-backend-module-gitlab': minor
---
Added the `{gitlab-integration-host}/user-id` annotation to store GitLab's user ID (immutable) in user entities. Also includes addition of the `userIdMatchingUserEntityAnnotation` sign-in resolver that matches users by the new ID.
+1
View File
@@ -80,6 +80,7 @@ This provider includes several resolvers out of the box that you can use:
- `emailMatchingUserEntityProfileEmail`: Matches the email address from the auth provider with the User entity that has a matching `spec.profile.email`. If no match is found, it will throw a `NotFoundError`.
- `emailLocalPartMatchingUserEntityName`: Matches the [local part](https://en.wikipedia.org/wiki/Email_address#Local-part) of the email address from the auth provider with the User entity that has a matching `name`. If no match is found, it will throw a `NotFoundError`.
- `usernameMatchingUserEntityName`: Matches the username from the auth provider with the User entity that has a matching `name`. If no match is found, it will throw a `NotFoundError`.
- `userIdMatchingUserEntityAnnotation`: Matches the GitHub user ID with the User entity that has a matching `github.com/user-id`. If no match is found, it will throw a `NotFoundError`.
:::note Note
+1
View File
@@ -72,6 +72,7 @@ This provider includes several resolvers out of the box that you can use:
- `emailMatchingUserEntityProfileEmail`: Matches the email address from the auth provider with the User entity that has a matching `spec.profile.email`. If no match is found, it will throw a `NotFoundError`.
- `emailLocalPartMatchingUserEntityName`: Matches the [local part](https://en.wikipedia.org/wiki/Email_address#Local-part) of the email address from the auth provider with the User entity that has a matching `name`. If no match is found, it will throw a `NotFoundError`.
- `usernameMatchingUserEntityName`: Matches the username from the auth provider with the User entity that has a matching `name`. If no match is found, it will throw a `NotFoundError`.
- `userIdMatchingUserEntityAnnotation`: Matches the GitLab user ID with the User entity that has a matching `gitlab.com/user-id` annotation (or `{integration-host}/user-id` for self-hosted GitLab instances). If no match is found, it will throw a `NotFoundError`.
:::note Note
@@ -251,6 +251,46 @@ browser when viewing that user.
This annotation can be used on a [User entity](descriptor-format.md#kind-user)
to note that it originated from that user on GitHub.
### github.com/user-id
```yaml
# Example:
metadata:
annotations:
github.com/user-id: '123456'
```
The value of this annotation is the numeric user ID that identifies a user on
[GitHub](https://github.com) (either the public one, or a private GitHub
Enterprise installation) that is related to this entity. Unlike the username,
which can be changed by the user, the user ID is immutable.
This annotation can be used on a [User entity](descriptor-format.md#kind-user)
to note that it originated from that user on GitHub. It enables the
`userIdMatchingUserEntityAnnotation` sign-in resolver to match users by their
GitHub user ID during authentication.
### gitlab.com/user-id
```yaml
# Example:
metadata:
annotations:
gitlab.com/user-id: '123456'
```
The value of this annotation is the numeric user ID that identifies a user on
[GitLab](https://gitlab.com) (either the public one, or a private GitLab
installation) that is related to this entity. For self-hosted GitLab instances,
the annotation key will be `{integration-host}/user-id` where
`{integration-host}` is the hostname of your GitLab instance. Unlike the
username, which can be changed, the user ID is immutable.
This annotation can be used on a [User entity](descriptor-format.md#kind-user)
to note that it originated from that user on GitLab. It enables the
`userIdMatchingUserEntityAnnotation` sign-in resolver to match users by their
GitLab user ID during authentication.
### gocd.org/pipelines
```yaml
@@ -44,6 +44,10 @@ export interface Config {
resolver: 'preferredUsernameMatchingUserEntityName';
dangerouslyAllowSignInWithoutUserInCatalog?: boolean;
}
| {
resolver: 'userIdMatchingUserEntityAnnotation';
dangerouslyAllowSignInWithoutUserInCatalog?: boolean;
}
>;
};
sessionDuration?: HumanDuration | string;
@@ -45,6 +45,10 @@ export interface Config {
resolver: 'emailMatchingUserEntityProfileEmail';
dangerouslyAllowSignInWithoutUserInCatalog?: boolean;
}
| {
resolver: 'userIdMatchingUserEntityAnnotation';
dangerouslyAllowSignInWithoutUserInCatalog?: boolean;
}
>;
};
sessionDuration?: HumanDuration | string;
@@ -17,13 +17,26 @@ export default authModuleGitlabProvider;
// @public (undocumented)
export const gitlabAuthenticator: OAuthAuthenticator<
PassportOAuthAuthenticatorHelper,
PassportProfile
GitlabProfile
>;
// @public (undocumented)
export type GitlabProfile = PassportProfile & {
id?: string;
profileUrl?: string;
};
// @public
export namespace gitlabSignInResolvers {
const usernameMatchingUserEntityName: SignInResolverFactory<
OAuthAuthenticatorResult<PassportProfile>,
OAuthAuthenticatorResult<GitlabProfile>,
| {
dangerouslyAllowSignInWithoutUserInCatalog?: boolean | undefined;
}
| undefined
>;
const userIdMatchingUserEntityAnnotation: SignInResolverFactory<
OAuthAuthenticatorResult<GitlabProfile>,
| {
dangerouslyAllowSignInWithoutUserInCatalog?: boolean | undefined;
}
@@ -23,7 +23,16 @@ import {
} from '@backstage/plugin-auth-node';
/** @public */
export const gitlabAuthenticator = createOAuthAuthenticator({
export type GitlabProfile = PassportProfile & {
id?: string;
profileUrl?: string;
};
/** @public */
export const gitlabAuthenticator = createOAuthAuthenticator<
PassportOAuthAuthenticatorHelper,
GitlabProfile
>({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
scopes: {
@@ -55,7 +64,7 @@ export const gitlabAuthenticator = createOAuthAuthenticator({
) => {
done(
undefined,
{ fullProfile, params, accessToken },
{ fullProfile: fullProfile as GitlabProfile, params, accessToken },
{ refreshToken },
);
},
@@ -20,6 +20,6 @@
* @packageDocumentation
*/
export { gitlabAuthenticator } from './authenticator';
export { gitlabAuthenticator, type GitlabProfile } from './authenticator';
export { authModuleGitlabProvider as default } from './module';
export { gitlabSignInResolvers } from './resolvers';
@@ -17,11 +17,12 @@
import {
createSignInResolverFactory,
OAuthAuthenticatorResult,
PassportProfile,
SignInInfo,
} from '@backstage/plugin-auth-node';
import { z } from 'zod';
import { GitlabProfile } from './authenticator';
/**
* Available sign-in resolvers for the GitLab auth provider.
*
@@ -39,7 +40,7 @@ export namespace gitlabSignInResolvers {
.optional(),
create(options = {}) {
return async (
info: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>,
info: SignInInfo<OAuthAuthenticatorResult<GitlabProfile>>,
ctx,
) => {
const { result } = info;
@@ -63,4 +64,51 @@ export namespace gitlabSignInResolvers {
};
},
});
/**
* Looks up the user by matching their GitLab user ID to the user-id annotation.
*/
export const userIdMatchingUserEntityAnnotation = createSignInResolverFactory(
{
optionsSchema: z
.object({
dangerouslyAllowSignInWithoutUserInCatalog: z.boolean().optional(),
})
.optional(),
create(options = {}) {
return async (
info: SignInInfo<OAuthAuthenticatorResult<GitlabProfile>>,
ctx,
) => {
const { fullProfile } = info.result;
const userId = fullProfile.id;
if (!userId) {
throw new Error(`GitLab user profile does not contain a user ID`);
}
if (!fullProfile.profileUrl) {
throw new Error(
`GitLab user profile does not contain a profile URL`,
);
}
const host = new URL(fullProfile.profileUrl).hostname;
return ctx.signInWithCatalogUser(
{
annotations: {
[`${host}/user-id`]: userId,
},
},
{
dangerousEntityRefFallback:
options?.dangerouslyAllowSignInWithoutUserInCatalog
? { entityRef: { name: userId } }
: undefined,
},
);
};
},
},
);
}
@@ -2191,6 +2191,7 @@ export const expected_single_user_entity: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/JohnDoe',
'example.com/user-login': 'https://gitlab.example/john_doe',
'example.com/user-id': '1',
},
name: 'JohnDoe',
},
@@ -2218,6 +2219,7 @@ export const expected_single_user_removed_entity: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/johndoe',
'example.com/user-login': '',
'example.com/user-id': '1',
},
name: 'johndoe',
},
@@ -2245,6 +2247,7 @@ export const expected_full_org_scan_entities: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/JohnDoe',
'example.com/user-login': 'https://gitlab.example/john_doe',
'example.com/user-id': '1',
},
name: 'JohnDoe',
},
@@ -2269,6 +2272,7 @@ export const expected_full_org_scan_entities: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/JaneDoe',
'example.com/user-login': 'https://gitlab.example/jane_doe',
'example.com/user-id': '2',
},
name: 'JaneDoe',
},
@@ -2294,6 +2298,7 @@ export const expected_full_org_scan_entities: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/MarySmith',
'example.com/user-login': 'https://gitlab.example/mary_smith',
'example.com/user-id': '3',
},
name: 'MarySmith',
},
@@ -2319,6 +2324,7 @@ export const expected_full_org_scan_entities: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/MarioMario',
'example.com/user-login': 'https://gitlab.example/mario_mario',
'example.com/user-id': '5',
},
name: 'MarioMario',
},
@@ -2395,6 +2401,7 @@ export const expected_full_org_scan_entities_saas: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testuser1',
'gitlab.com/user-login': 'https://gitlab.com/testuser1',
'gitlab.com/user-id': '12',
'gitlab.com/saml-external-uid': '51',
},
name: 'testuser1',
@@ -2421,6 +2428,7 @@ export const expected_full_org_scan_entities_saas: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testuser2',
'gitlab.com/user-login': 'https://gitlab.com/testuser2',
'gitlab.com/user-id': '34',
'gitlab.com/saml-external-uid': '52',
},
name: 'testuser2',
@@ -2447,6 +2455,7 @@ export const expected_full_org_scan_entities_saas: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testuser4',
'gitlab.com/user-login': 'https://gitlab.com/testuser4',
'gitlab.com/user-id': '44',
'gitlab.com/saml-external-uid': '54',
},
name: 'testuser4',
@@ -2473,6 +2482,7 @@ export const expected_full_org_scan_entities_saas: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testuser3',
'gitlab.com/user-login': 'https://gitlab.com/testuser3',
'gitlab.com/user-id': '33',
'gitlab.com/saml-external-uid': '53',
},
name: 'testuser3',
@@ -2503,6 +2513,7 @@ export const expected_full_org_scan_entities_includeUsersWithoutSeat_saas: MockO
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testuser1',
'gitlab.com/user-login': 'https://gitlab.com/testuser1',
'gitlab.com/user-id': '12',
'gitlab.com/saml-external-uid': '51',
},
name: 'testuser1',
@@ -2529,6 +2540,7 @@ export const expected_full_org_scan_entities_includeUsersWithoutSeat_saas: MockO
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testuser2',
'gitlab.com/user-login': 'https://gitlab.com/testuser2',
'gitlab.com/user-id': '34',
'gitlab.com/saml-external-uid': '52',
},
name: 'testuser2',
@@ -2555,6 +2567,7 @@ export const expected_full_org_scan_entities_includeUsersWithoutSeat_saas: MockO
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testusernoseat1',
'gitlab.com/user-login': 'https://gitlab.com/testusernoseat1',
'gitlab.com/user-id': '36',
'gitlab.com/saml-external-uid': '60',
},
name: 'testusernoseat1',
@@ -2581,6 +2594,7 @@ export const expected_full_org_scan_entities_includeUsersWithoutSeat_saas: MockO
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testuser4',
'gitlab.com/user-login': 'https://gitlab.com/testuser4',
'gitlab.com/user-id': '44',
'gitlab.com/saml-external-uid': '54',
},
name: 'testuser4',
@@ -2607,6 +2621,7 @@ export const expected_full_org_scan_entities_includeUsersWithoutSeat_saas: MockO
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testuser3',
'gitlab.com/user-login': 'https://gitlab.com/testuser3',
'gitlab.com/user-id': '33',
'gitlab.com/saml-external-uid': '53',
},
name: 'testuser3',
@@ -2673,6 +2688,7 @@ export const expected_subgroup_org_scan_entities_saas: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://gitlab.com/testuser1',
'gitlab.com/user-login': 'https://gitlab.com/testuser1',
'gitlab.com/user-id': '12',
'gitlab.com/saml-external-uid': '51',
},
name: 'testuser1',
@@ -2702,6 +2718,7 @@ export const expected_full_members_group_org_scan_entities: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/JohnDoe',
'example.com/user-login': 'https://gitlab.example/john_doe',
'example.com/user-id': '1',
},
name: 'JohnDoe',
},
@@ -2726,6 +2743,7 @@ export const expected_full_members_group_org_scan_entities: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/JaneDoe',
'example.com/user-login': 'https://gitlab.example/jane_doe',
'example.com/user-id': '2',
},
name: 'JaneDoe',
},
@@ -2751,6 +2769,7 @@ export const expected_full_members_group_org_scan_entities: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/MarySmith',
'example.com/user-login': 'https://gitlab.example/mary_smith',
'example.com/user-id': '3',
},
name: 'MarySmith',
},
@@ -2776,6 +2795,7 @@ export const expected_full_members_group_org_scan_entities: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/MarioMario',
'example.com/user-login': 'https://gitlab.example/mario_mario',
'example.com/user-id': '5',
},
name: 'MarioMario',
},
@@ -2851,6 +2871,7 @@ export const expected_group_members_group_org_scan_entities: MockObject[] = [
'backstage.io/managed-by-origin-location':
'url:https://example.com/JohnDoe',
'example.com/user-login': 'https://gitlab.example/john_doe',
'example.com/user-id': '1',
},
name: 'JohnDoe',
},
@@ -103,6 +103,8 @@ export function defaultUserTransformer(
annotations[`${options.integrationConfig.host}/user-login`] =
options.user.web_url;
annotations[`${options.integrationConfig.host}/user-id`] =
options.user.id.toString();
if (options.user?.group_saml_identity?.extern_uid) {
annotations[`${options.integrationConfig.host}/saml-external-uid`] =
options.user.group_saml_identity.extern_uid;