diff --git a/.changeset/slimy-zebras-lie.md b/.changeset/slimy-zebras-lie.md new file mode 100644 index 0000000000..4ea1ce1791 --- /dev/null +++ b/.changeset/slimy-zebras-lie.md @@ -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. diff --git a/.changeset/vast-rockets-dig.md b/.changeset/vast-rockets-dig.md new file mode 100644 index 0000000000..3340fabb2c --- /dev/null +++ b/.changeset/vast-rockets-dig.md @@ -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. diff --git a/docs/auth/github/provider.md b/docs/auth/github/provider.md index fa44ae9405..e20ecb2ef7 100644 --- a/docs/auth/github/provider.md +++ b/docs/auth/github/provider.md @@ -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 diff --git a/docs/auth/gitlab/provider.md b/docs/auth/gitlab/provider.md index a76c03d10f..d6751f20ff 100644 --- a/docs/auth/gitlab/provider.md +++ b/docs/auth/gitlab/provider.md @@ -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 diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index d8a2e1d232..0143d3b4d5 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -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 diff --git a/plugins/auth-backend-module-github-provider/config.d.ts b/plugins/auth-backend-module-github-provider/config.d.ts index 15ed412a6f..90bf58f976 100644 --- a/plugins/auth-backend-module-github-provider/config.d.ts +++ b/plugins/auth-backend-module-github-provider/config.d.ts @@ -44,6 +44,10 @@ export interface Config { resolver: 'preferredUsernameMatchingUserEntityName'; dangerouslyAllowSignInWithoutUserInCatalog?: boolean; } + | { + resolver: 'userIdMatchingUserEntityAnnotation'; + dangerouslyAllowSignInWithoutUserInCatalog?: boolean; + } >; }; sessionDuration?: HumanDuration | string; diff --git a/plugins/auth-backend-module-gitlab-provider/config.d.ts b/plugins/auth-backend-module-gitlab-provider/config.d.ts index 8cbb253e2c..56f86f53d9 100644 --- a/plugins/auth-backend-module-gitlab-provider/config.d.ts +++ b/plugins/auth-backend-module-gitlab-provider/config.d.ts @@ -45,6 +45,10 @@ export interface Config { resolver: 'emailMatchingUserEntityProfileEmail'; dangerouslyAllowSignInWithoutUserInCatalog?: boolean; } + | { + resolver: 'userIdMatchingUserEntityAnnotation'; + dangerouslyAllowSignInWithoutUserInCatalog?: boolean; + } >; }; sessionDuration?: HumanDuration | string; diff --git a/plugins/auth-backend-module-gitlab-provider/report.api.md b/plugins/auth-backend-module-gitlab-provider/report.api.md index cb914deeea..e8797ec351 100644 --- a/plugins/auth-backend-module-gitlab-provider/report.api.md +++ b/plugins/auth-backend-module-gitlab-provider/report.api.md @@ -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, + OAuthAuthenticatorResult, + | { + dangerouslyAllowSignInWithoutUserInCatalog?: boolean | undefined; + } + | undefined + >; + const userIdMatchingUserEntityAnnotation: SignInResolverFactory< + OAuthAuthenticatorResult, | { dangerouslyAllowSignInWithoutUserInCatalog?: boolean | undefined; } diff --git a/plugins/auth-backend-module-gitlab-provider/src/authenticator.ts b/plugins/auth-backend-module-gitlab-provider/src/authenticator.ts index 8e93921b63..225d26c4c9 100644 --- a/plugins/auth-backend-module-gitlab-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-gitlab-provider/src/authenticator.ts @@ -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 }, ); }, diff --git a/plugins/auth-backend-module-gitlab-provider/src/index.ts b/plugins/auth-backend-module-gitlab-provider/src/index.ts index c581b76005..5ef2f7c76a 100644 --- a/plugins/auth-backend-module-gitlab-provider/src/index.ts +++ b/plugins/auth-backend-module-gitlab-provider/src/index.ts @@ -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'; diff --git a/plugins/auth-backend-module-gitlab-provider/src/resolvers.ts b/plugins/auth-backend-module-gitlab-provider/src/resolvers.ts index d5715f7e91..0b6abd0f28 100644 --- a/plugins/auth-backend-module-gitlab-provider/src/resolvers.ts +++ b/plugins/auth-backend-module-gitlab-provider/src/resolvers.ts @@ -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>, + info: SignInInfo>, 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>, + 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, + }, + ); + }; + }, + }, + ); } diff --git a/plugins/catalog-backend-module-gitlab/src/__testUtils__/mocks.ts b/plugins/catalog-backend-module-gitlab/src/__testUtils__/mocks.ts index d64b266183..e306090f19 100644 --- a/plugins/catalog-backend-module-gitlab/src/__testUtils__/mocks.ts +++ b/plugins/catalog-backend-module-gitlab/src/__testUtils__/mocks.ts @@ -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', }, diff --git a/plugins/catalog-backend-module-gitlab/src/lib/defaultTransformers.ts b/plugins/catalog-backend-module-gitlab/src/lib/defaultTransformers.ts index 912e5a0502..9ce9c29df1 100644 --- a/plugins/catalog-backend-module-gitlab/src/lib/defaultTransformers.ts +++ b/plugins/catalog-backend-module-gitlab/src/lib/defaultTransformers.ts @@ -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;