diff --git a/docs/auth.md b/docs/auth.md index 654a6b6301..74e0f3f21f 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -190,3 +190,27 @@ auth: providers: # provider configs ... ``` + +### includeTransientGroupOwnership configuration value + +This option allows users to add transient parent groups into the resolved user group membership during the authentication process. i.e., the parent group of the user's direct group will be included in the user ownership entities. By default, this option is set to false. + +For instance, with this group hierarchy: + +``` +group_admin + └── group_developers + └── user_alice +``` + +- If `includeTransientGroupOwnership: false`, `test_user` is only a member of `group_developers`. +- If `includeTransientGroupOwnership: true`, `test_user` is a member of `group_developers` AND `group_admin`. + +To enable this option: + +```yaml +includeTransientGroupOwnership: true +auth: + providers: + # provider configs ... +``` \ No newline at end of file diff --git a/packages/app/config.d.ts b/packages/app/config.d.ts index 3fccb49526..887e4ee552 100644 --- a/packages/app/config.d.ts +++ b/packages/app/config.d.ts @@ -134,4 +134,9 @@ export interface Config { * @visibility frontend */ dangerouslyAllowSignInWithoutUserInCatalog?: boolean; + /** + * The option to includes transient parent groups when determining user group membership + * @visibility frontend + */ + includeTransientGroupOwnership?: boolean; } diff --git a/packages/backend/package.json b/packages/backend/package.json index b806ff747c..50db9c89f3 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -27,6 +27,7 @@ "@backstage/backend-defaults": "0.7.0", "@backstage/backend-dynamic-feature-service": "0.5.3", "@backstage/backend-plugin-api": "1.1.1", + "@backstage/catalog-client": "1.9.1", "@backstage/catalog-model": "1.7.3", "@backstage/cli-node": "0.2.12", "@backstage/config": "1.3.2", @@ -64,7 +65,9 @@ "winston": "3.14.2" }, "devDependencies": { + "@backstage/backend-test-utils": "1.2.1", "@backstage/cli": "0.29.5", + "@backstage/plugin-catalog-node": "1.15.1", "@types/express": "4.17.21", "@types/global-agent": "2.1.3", "prettier": "3.4.2" diff --git a/packages/backend/src/modules/authProvidersModule.test.ts b/packages/backend/src/modules/authProvidersModule.test.ts new file mode 100644 index 0000000000..6799ba7bbc --- /dev/null +++ b/packages/backend/src/modules/authProvidersModule.test.ts @@ -0,0 +1,75 @@ +import { AuthResolverContext } from '@backstage/plugin-auth-node'; + +import { signInWithCatalogUserOptional } from './authProvidersModule'; + +jest.mock('@backstage/config-loader', () => ({ + ConfigSources: { + default: jest.fn(), + toConfig: jest.fn().mockResolvedValue({ + getOptionalBoolean: jest.fn().mockReturnValue(false), // mock dangerouslyAllowSignInWithoutUserInCatalog to false by default + }), + }, +})); + +describe('signInWithCatalogUserOptional', () => { + let ctx: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + ctx = { + issueToken: jest.fn().mockResolvedValue({ + token: 'issuedBackstageUserToken', + }), + findCatalogUser: jest.fn(), + signInWithCatalogUser: jest.fn().mockResolvedValue({ + token: 'backstageToken', + }), + }; + }); + + it('should return the signedInUser with token if the user exists in the catalog', async () => { + const signedInUser = await signInWithCatalogUserOptional('test-user', ctx); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith({ + entityRef: { name: 'test-user' }, + }); + + expect(signedInUser).toEqual({ token: 'backstageToken' }); + }); + + it('should issue a token if the user does not exist in catalog and dangerouslyAllowSignInWithoutUserInCatalog is false (by default)', async () => { + (ctx.signInWithCatalogUser as jest.Mock).mockRejectedValue( + new Error('User not found'), + ); + + await expect( + signInWithCatalogUserOptional('test-user', ctx), + ).rejects.toThrow( + `Sign in failed: User not found in the RHDH software catalog. Verify that users/groups are synchronized to the software catalog. For non-production environments, manually provision the user or disable the user provisioning requirement. Refer to the RHDH Authentication documentation for further details.`, + ); + + expect(ctx.signInWithCatalogUser).toHaveBeenCalledWith({ + entityRef: { name: 'test-user' }, + }); + }); + + it('should issue a token if the user does not exist in catalog and dangerouslyAllowSignInWithoutUserInCatalog is true', async () => { + const { ConfigSources } = require('@backstage/config-loader'); + ConfigSources.toConfig.mockResolvedValue({ + getOptionalBoolean: jest.fn().mockReturnValue(true), + }); + (ctx.signInWithCatalogUser as jest.Mock).mockRejectedValue( + new Error('User not found'), + ); + + const result = await signInWithCatalogUserOptional('test-user', ctx); + expect(ctx.issueToken).toHaveBeenCalledWith({ + claims: { + sub: 'user:default/test-user', + ent: ['user:default/test-user'], + }, + }); + + expect(result).toEqual({ token: 'issuedBackstageUserToken' }); + }); +}); diff --git a/packages/backend/src/modules/authProvidersModule.ts b/packages/backend/src/modules/authProvidersModule.ts index beb6c43881..de9d2ed548 100644 --- a/packages/backend/src/modules/authProvidersModule.ts +++ b/packages/backend/src/modules/authProvidersModule.ts @@ -17,6 +17,7 @@ import { oidcSignInResolvers, } from '@backstage/plugin-auth-backend-module-oidc-provider'; import { + authOwnershipResolutionExtensionPoint, AuthProviderFactory, authProvidersExtensionPoint, AuthResolverCatalogUserQuery, @@ -24,6 +25,8 @@ import { createOAuthProviderFactory, } from '@backstage/plugin-auth-node'; +import { TransientGroupOwnershipResolver } from '../transientGroupOwnershipResolver'; + /** * Function is responsible for signing in a user with the catalog user and * creating an entity reference based on the provided name parameter. @@ -34,7 +37,7 @@ import { * @param ctx * @returns */ -async function signInWithCatalogUserOptional( +export async function signInWithCatalogUserOptional( name: string | AuthResolverCatalogUserQuery, ctx: AuthResolverContext, ) { @@ -283,9 +286,19 @@ const authProvidersModule = createBackendModule({ deps: { config: coreServices.rootConfig, authProviders: authProvidersExtensionPoint, + authOwnershipResolution: authOwnershipResolutionExtensionPoint, logger: coreServices.logger, + discovery: coreServices.discovery, + auth: coreServices.auth }, - async init({ config, authProviders, logger }) { + async init({ + config, + authProviders, + authOwnershipResolution, + logger, + discovery, + auth + }) { const providersConfig = config.getConfig('auth.providers'); const authFactories: ProviderFactories = {}; providersConfig @@ -304,6 +317,11 @@ const authProvidersModule = createBackendModule({ logger.info( `Enabled Provider Factories : ${JSON.stringify(providerFactories)}`, ); + const transientGroupOwnershipResolver = + new TransientGroupOwnershipResolver({ discovery, config, auth }); + authOwnershipResolution.setAuthOwnershipResolver( + transientGroupOwnershipResolver, + ); Object.entries(providerFactories).forEach(([providerId, factory]) => { authProviders.registerProvider({ providerId, factory }); diff --git a/packages/backend/src/transientGroupOwnershipResolver.test.ts b/packages/backend/src/transientGroupOwnershipResolver.test.ts new file mode 100644 index 0000000000..5606499cc3 --- /dev/null +++ b/packages/backend/src/transientGroupOwnershipResolver.test.ts @@ -0,0 +1,382 @@ +import { mockServices } from '@backstage/backend-test-utils'; +import { + GroupEntity, + RELATION_CHILD_OF, + RELATION_MEMBER_OF, + UserEntity, +} from '@backstage/catalog-model'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; + +import { TransientGroupOwnershipResolver } from './transientGroupOwnershipResolver'; + +describe('resolveParentGroups', () => { + const mockUser: UserEntity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'User', + metadata: { + name: 'test-user', + }, + spec: {}, + relations: [ + { + type: RELATION_MEMBER_OF, + targetRef: 'group:default/child-group', + }, + ], + }; + + const mockGroups: Array = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'child-group', namespace: 'default' }, + spec: { + type: 'group', + children: [], + }, + relations: [ + { + type: RELATION_CHILD_OF, + targetRef: 'group:default/parent-group', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'parent-group', namespace: 'default' }, + spec: { + type: 'group', + children: [], + }, + relations: [ + { + type: RELATION_CHILD_OF, + targetRef: 'group:default/root-group', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'root-group', namespace: 'default' }, + spec: { + type: 'group', + children: [], + }, + }, + ]; + + const config = mockServices.rootConfig({ + data: { includeTransientGroupOwnership: true }, + }); + + it('should resolve parent groups recursively', async () => { + const catalogApi = catalogServiceMock({ entities: mockGroups }); + const resolver = new TransientGroupOwnershipResolver({ + discovery: mockServices.discovery(), + config: config, + auth: mockServices.auth.mock({ + getPluginRequestToken: async () => ({ token: 'test-token' }), + }), + catalog: catalogApi, + }); + const parentGroups = await resolver.resolveOwnershipEntityRefs(mockUser); + + expect(parentGroups).toEqual({ + ownershipEntityRefs: [ + 'user:default/test-user', + 'group:default/child-group', + 'group:default/parent-group', + 'group:default/root-group', + ], + }); + }); + + it('should handle groups without parents', async () => { + const mockGroupsWithoutParent: Array = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'child-group', namespace: 'default' }, + spec: { + type: 'group', + children: [], + }, + relations: [], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'parent-group', namespace: 'default' }, + spec: { + type: 'group', + children: [], + }, + }, + ]; + const catalogApi = catalogServiceMock({ + entities: mockGroupsWithoutParent, + }); + const resolver = new TransientGroupOwnershipResolver({ + discovery: mockServices.discovery(), + config: config, + auth: mockServices.auth.mock({ + getPluginRequestToken: async () => ({ token: 'test-token' }), + }), + catalog: catalogApi, + }); + const parentGroups = await resolver.resolveOwnershipEntityRefs(mockUser); + + expect(parentGroups).toEqual({ + ownershipEntityRefs: [ + 'user:default/test-user', + 'group:default/child-group', + ], + }); + }); + + it('should handle user belonging in multiple groups', async () => { + const mockGroupsMultipleGroups: Array = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'child-group', namespace: 'default' }, + spec: { + type: 'group', + children: [], + }, + relations: [ + { + type: RELATION_CHILD_OF, + targetRef: 'group:default/parent-group', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'parent-group', namespace: 'default' }, + spec: { + type: 'group', + children: [], + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'other-group', namespace: 'default' }, + spec: { + type: 'group', + children: [], + }, + }, + ]; + const catalogApi = catalogServiceMock({ + entities: mockGroupsMultipleGroups, + }); + const resolver = new TransientGroupOwnershipResolver({ + discovery: mockServices.discovery(), + config: config, + auth: mockServices.auth.mock({ + getPluginRequestToken: async () => ({ token: 'test-token' }), + }), + catalog: catalogApi, + }); + const mockUserInMultipleGroups: UserEntity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'User', + metadata: { + name: 'test-user', + }, + spec: {}, + relations: [ + { + type: RELATION_MEMBER_OF, + targetRef: 'group:default/child-group', + }, + { + type: RELATION_MEMBER_OF, + targetRef: 'group:default/other-group', + }, + ], + }; + const parentGroups = await resolver.resolveOwnershipEntityRefs( + mockUserInMultipleGroups, + ); + + expect(parentGroups).toEqual({ + ownershipEntityRefs: [ + 'user:default/test-user', + 'group:default/child-group', + 'group:default/parent-group', + 'group:default/other-group', + ], + }); + }); + + it('should not resolve parent groups recursively by default (with includeTransientGroupOwnership to false)', async () => { + const catalogApi = catalogServiceMock({ entities: mockGroups }); + const resolver = new TransientGroupOwnershipResolver({ + discovery: mockServices.discovery(), + config: mockServices.rootConfig(), + auth: mockServices.auth.mock({ + getPluginRequestToken: async () => ({ token: 'test-token' }), + }), + catalog: catalogApi, + }); + const parentGroups = await resolver.resolveOwnershipEntityRefs(mockUser); + + expect(parentGroups).toEqual({ + ownershipEntityRefs: [ + 'user:default/test-user', + 'group:default/child-group', + ], + }); + }); + + it('should handle an user with no group membership', async () => { + const catalogApi = catalogServiceMock({ entities: [] }); + const resolver = new TransientGroupOwnershipResolver({ + discovery: mockServices.discovery(), + config: config, + auth: mockServices.auth.mock({ + getPluginRequestToken: async () => ({ token: 'test-token' }), + }), + catalog: catalogApi, + }); + + const mockUserNoGroup: UserEntity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'User', + metadata: { name: 'user' }, + spec: {}, + relations: [], + }; + + const result = await resolver.resolveOwnershipEntityRefs(mockUserNoGroup); + + expect(result).toEqual({ ownershipEntityRefs: ['user:default/user'] }); + }); + + it('should resolve group memberships with different namespaces', async () => { + const mockGroupsWithDifferentNamespace: Array = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group-a', namespace: 'default' }, + spec: { type: 'group', children: [] }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group-b', namespace: 'other-namespace' }, + spec: { type: 'group', children: [] }, + relations: [ + { + type: RELATION_CHILD_OF, + targetRef: 'group:other-namespace/group-b-parent', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'group-b-parent', namespace: 'other-namespace' }, + spec: { type: 'group', children: [''] }, + }, + ]; + + const catalogApi = catalogServiceMock({ + entities: mockGroupsWithDifferentNamespace, + }); + const resolver = new TransientGroupOwnershipResolver({ + discovery: mockServices.discovery(), + config: config, + auth: mockServices.auth.mock({ + getPluginRequestToken: async () => ({ token: 'test-token' }), + }), + catalog: catalogApi, + }); + + const mockUserInMultipleNamespace: UserEntity = { + apiVersion: 'backstage.io/v1beta1', + kind: 'User', + metadata: { name: 'test-user' }, + spec: {}, + relations: [ + { type: RELATION_MEMBER_OF, targetRef: 'group:default/group-a' }, + { + type: RELATION_MEMBER_OF, + targetRef: 'group:other-namespace/group-b', + }, + ], + }; + + const result = await resolver.resolveOwnershipEntityRefs( + mockUserInMultipleNamespace, + ); + + expect(result).toEqual({ + ownershipEntityRefs: [ + 'user:default/test-user', + 'group:default/group-a', + 'group:other-namespace/group-b', + 'group:other-namespace/group-b-parent', + ], + }); + }); + + it('should handle user with cyclic group memberships', async () => { + const mockCyclicGroups: Array = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'child-group', namespace: 'default' }, + spec: { type: 'group', children: [] }, + relations: [ + { + type: RELATION_CHILD_OF, + targetRef: 'group:default/parent-group', + }, + ], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'parent-group', namespace: 'default' }, + spec: { type: 'group', children: [] }, + relations: [ + { + type: RELATION_CHILD_OF, + targetRef: 'group:default/child-group', + }, + ], + } + ]; + + const catalogApi = catalogServiceMock({ + entities: mockCyclicGroups, + }); + const resolver = new TransientGroupOwnershipResolver({ + discovery: mockServices.discovery(), + config: config, + auth: mockServices.auth.mock({ + getPluginRequestToken: async () => ({ token: 'test-token' }), + }), + catalog: catalogApi, + }); + + const result = await resolver.resolveOwnershipEntityRefs( + mockUser, + ); + + expect(result).toEqual({ + ownershipEntityRefs: [ + 'user:default/test-user', + 'group:default/child-group', + 'group:default/parent-group' + ], + }); + }); +}); diff --git a/packages/backend/src/transientGroupOwnershipResolver.ts b/packages/backend/src/transientGroupOwnershipResolver.ts new file mode 100644 index 0000000000..6755791417 --- /dev/null +++ b/packages/backend/src/transientGroupOwnershipResolver.ts @@ -0,0 +1,107 @@ +import { AuthService, DiscoveryService } from '@backstage/backend-plugin-api'; +import { CatalogApi, CatalogClient } from '@backstage/catalog-client'; +import { + Entity, + GroupEntity, + RELATION_CHILD_OF, + RELATION_MEMBER_OF, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { Config } from '@backstage/config'; +import { AuthOwnershipResolver } from '@backstage/plugin-auth-node'; + +export class TransientGroupOwnershipResolver implements AuthOwnershipResolver { + private readonly catalogApi: CatalogApi; + private readonly config: Config; + private readonly auth: AuthService; + + constructor(deps: { + discovery: DiscoveryService; + config: Config; + auth: AuthService; + catalog?: CatalogApi; + }) { + this.catalogApi = deps.catalog + ? deps.catalog + : new CatalogClient({ discoveryApi: deps.discovery }); + this.config = deps.config; + this.auth = deps.auth; + } + + private async resolveParentGroups( + groupRefs: string[], + processedGroups: Set = new Set(), +): Promise { + const allTransientGroupRefs = new Set(); + + for (const groupRef of groupRefs) { + if (processedGroups.has(groupRef)) continue; + processedGroups.add(groupRef); + + if (allTransientGroupRefs.has(groupRef)) continue; + + allTransientGroupRefs.add(groupRef); + + const { token } = await this.auth.getPluginRequestToken({ + onBehalfOf: await this.auth.getOwnServiceCredentials(), + targetPluginId: 'catalog', + }); + + const res = await this.catalogApi.getEntitiesByRefs({ + entityRefs: [groupRef], + fields: ['kind', 'relations'], + }, + { token } + ); + + const groupEntity = res.items?.find( + e => e?.kind.toLocaleLowerCase('en-US') === 'group', + ) as GroupEntity | undefined; + + if (!groupEntity) continue; + + const parentGroupRefs = + groupEntity.relations + ?.filter(relation => relation.type === RELATION_CHILD_OF) + .map(relation => relation.targetRef) || []; + + const parentGroups = await this.resolveParentGroups(parentGroupRefs, processedGroups); + parentGroups.forEach(parentGroup => + allTransientGroupRefs.add(parentGroup), + ); + } + + return Array.from(allTransientGroupRefs); + } + + /** + * Returns the user’s own entity reference and direct group memberships. + * Includes nested group hierarchies if the `includeTransientGroupOwnership` config is enabled. + * + * @param entity user entity to resolve ownership references for + * + * @returns object containing the ownership entity references for the given user entity + */ + async resolveOwnershipEntityRefs( + entity: Entity, + ): Promise<{ ownershipEntityRefs: string[] }> { + let membershipRefs = + entity.relations + ?.filter( + r => + r.type === RELATION_MEMBER_OF && r.targetRef.startsWith('group:'), + ) + .map(r => r.targetRef) ?? []; + + const includeTransientGroupOwnership = + this.config.getOptionalBoolean('includeTransientGroupOwnership') || false; + if (includeTransientGroupOwnership) { + membershipRefs = await this.resolveParentGroups(membershipRefs); + } + return { + ownershipEntityRefs: Array.from( + new Set([stringifyEntityRef(entity), ...membershipRefs]), + ), + }; + } +} diff --git a/yarn.lock b/yarn.lock index f6146957cf..3785f7cf78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6781,7 +6781,7 @@ __metadata: languageName: node linkType: hard -"@backstage/plugin-catalog-node@npm:^1.12.4, @backstage/plugin-catalog-node@npm:^1.13.1, @backstage/plugin-catalog-node@npm:^1.15.0, @backstage/plugin-catalog-node@npm:^1.15.1": +"@backstage/plugin-catalog-node@npm:1.15.1, @backstage/plugin-catalog-node@npm:^1.12.4, @backstage/plugin-catalog-node@npm:^1.13.1, @backstage/plugin-catalog-node@npm:^1.15.0, @backstage/plugin-catalog-node@npm:^1.15.1": version: 1.15.1 resolution: "@backstage/plugin-catalog-node@npm:1.15.1" dependencies: @@ -22748,6 +22748,8 @@ __metadata: "@backstage/backend-defaults": 0.7.0 "@backstage/backend-dynamic-feature-service": 0.5.3 "@backstage/backend-plugin-api": 1.1.1 + "@backstage/backend-test-utils": 1.2.1 + "@backstage/catalog-client": 1.9.1 "@backstage/catalog-model": 1.7.3 "@backstage/cli": 0.29.5 "@backstage/cli-node": 0.2.12 @@ -22762,6 +22764,7 @@ __metadata: "@backstage/plugin-catalog-backend-module-logs": 0.1.6 "@backstage/plugin-catalog-backend-module-openapi": 0.2.6 "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": 0.2.4 + "@backstage/plugin-catalog-node": 1.15.1 "@backstage/plugin-events-backend": 0.4.1 "@backstage/plugin-events-node": 0.4.7 "@backstage/plugin-proxy-backend": 0.5.10