diff --git a/.github/workflows/feature-branch.yml b/.github/workflows/feature-branch.yml index 25b0558..b3b3204 100644 --- a/.github/workflows/feature-branch.yml +++ b/.github/workflows/feature-branch.yml @@ -70,6 +70,21 @@ jobs: run: | echo -e "$CONTENTS" > src/app-config.d/github-app-credentials.yaml + # Couldn't store JSON creds while retaining proper formatting so going to do this in two steps + - name: Write Google Admin API creds + id: google-admin-creds + shell: bash + env: + CONTENTS: ${{ secrets.CATALOG_GOOGLE_JWT_KEYS }} + run: | + echo -e "$CONTENTS" > src/app-config.d/credentials/google-jwt.keys.yaml + + - name: Convert Google Admin API creds to YAML + id: google-admin-creds-yaml + shell: bash + run: | + yq -p yaml -o json src/app-config.d/credentials/google-jwt.keys.yaml > src/app-config.d/credentials/google-jwt.keys.json + - name: Compile Typescript id: compile shell: bash diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f0e3634..1f08694 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -68,7 +68,22 @@ jobs: env: CONTENTS: ${{ secrets.X_GITHUB_APP_CREDS }} run: | - echo -e "$CONTENTS" > src/app-config.d/github-app-credentials.yaml + echo -e "$CONTENTS" > src/app-config.d/credentials/github-app-credentials.yaml + + # Couldn't store JSON creds while retaining proper formatting so going to do this in two steps + - name: Write Google Admin API creds + id: google-admin-creds + shell: bash + env: + CONTENTS: ${{ secrets.CATALOG_GOOGLE_JWT_KEYS }} + run: | + echo -e "$CONTENTS" > src/app-config.d/credentials/google-jwt.keys.yaml + + - name: Convert Google Admin API creds to YAML + id: google-admin-creds-yaml + shell: bash + run: | + yq -p yaml -o json src/app-config.d/credentials/google-jwt.keys.yaml > src/app-config.d/credentials/google-jwt.keys.json - name: Compile Typescript id: compile diff --git a/.gitignore b/.gitignore index e421440..449e0da 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ codepipeline-config-*.yaml # AWS SAM .aws-sam/ + +# Backstage credentials +src/app-config.d/credentials diff --git a/src/app-config.d/catalogs/groups.yaml b/src/app-config.d/catalogs/groups.yaml deleted file mode 100644 index 66f0796..0000000 --- a/src/app-config.d/catalogs/groups.yaml +++ /dev/null @@ -1,24 +0,0 @@ ---- -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: admins - description: Backstage Admins -spec: - type: team - profile: - displayName: Admins - children: [] - ---- -apiVersion: backstage.io/v1alpha1 -kind: Group -metadata: - name: developers - description: Developer users -spec: - type: team - profile: - displayName: Developers - email: developers-all@serverlessops.io - children: [] \ No newline at end of file diff --git a/src/app-config.d/catalogs/users.yaml b/src/app-config.d/catalogs/users.yaml deleted file mode 100644 index 59927fe..0000000 --- a/src/app-config.d/catalogs/users.yaml +++ /dev/null @@ -1,22 +0,0 @@ ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: tom.mclaughlin -spec: - profile: - displayName: Tom McLaughlin - email: tom@serverlessops.io - picture: https://2.gravatar.com/avatar/0c1efb912f3e0ea730f7cd10bfb32dba87a04214a34db6b50b20f1c6cbe1444a?size=512 - memberOf: [developers, admins] - ---- -apiVersion: backstage.io/v1alpha1 -kind: User -metadata: - name: alex.kambeitz -spec: - profile: - displayName: Alex Kambeitz - email: alex.kambeitz@serverlessops.io - memberOf: [developers] \ No newline at end of file diff --git a/src/app-config.d/credentials/.keep b/src/app-config.d/credentials/.keep new file mode 100644 index 0000000..e69de29 diff --git a/src/app-config.production.yaml b/src/app-config.production.yaml index 34b8b68..e2a49db 100644 --- a/src/app-config.production.yaml +++ b/src/app-config.production.yaml @@ -52,10 +52,19 @@ integrations: github: - host: github.com apps: - - $include: /app/app-config.d/github-app-credentials.yaml + - $include: /app/app-config.d/credentials/github-app-credentials.yaml catalog: providers: + google: + auth: + adminAccountEmail: tom@serverlessops.io + clientCredentials: + $include: /app/app-config.d/credentials/google-jwt.keys.yaml + schedule: + initialDelay: { seconds: 5 } + frequency: { minutes: 1 } + timeout: { seconds: 30 } serverlessops-catalog: baseUrl: ${SERVERLESSOPS_CATALOG_API_URL} namespace: default @@ -78,14 +87,6 @@ catalog: # See https://backstage.io/docs/features/software-catalog/#adding-components-to-the-catalog for more details # on how to get entities into the catalog. locations: - - type: file - target: /app/app-config.d/catalogs/users.yaml - rules: - - allow: [User] - - type: file - target: /app/app-config.d/catalogs/groups.yaml - rules: - - allow: [Groups] - type: file target: /app/app-config.d/scaffolders/domain-add.yaml rules: diff --git a/src/packages/backend/package.json b/src/packages/backend/package.json index 3a19aa5..557a537 100644 --- a/src/packages/backend/package.json +++ b/src/packages/backend/package.json @@ -41,6 +41,7 @@ "@backstage/plugin-search-backend-module-techdocs": "^0.2.2", "@backstage/plugin-search-backend-node": "^1.3.2", "@backstage/plugin-techdocs-backend": "^1.10.13", + "@internal/backstage-plugin-catalog-backend-module-google": "workspace:^", "@internal/backstage-plugin-catalog-backend-module-serverlessops-catalog": "workspace:^", "@internal/backstage-plugin-scaffolder-backend-module-serverlessops": "workspace:^", "app": "link:../app", diff --git a/src/packages/backend/src/index.ts b/src/packages/backend/src/index.ts index ead7d12..6d78c78 100644 --- a/src/packages/backend/src/index.ts +++ b/src/packages/backend/src/index.ts @@ -56,4 +56,5 @@ backend.add(import('@backstage/plugin-scaffolder-backend-module-github')); backend.add(import('@internal/backstage-plugin-catalog-backend-module-serverlessops-catalog')); backend.add(import('@internal/backstage-plugin-scaffolder-backend-module-serverlessops')); +backend.add(import('@internal/backstage-plugin-catalog-backend-module-google')); backend.start(); diff --git a/src/plugins/plugin-catalog-backend-module-google/.eslintrc.js b/src/plugins/plugin-catalog-backend-module-google/.eslintrc.js new file mode 100644 index 0000000..e2a53a6 --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/src/plugins/plugin-catalog-backend-module-google/README.md b/src/plugins/plugin-catalog-backend-module-google/README.md new file mode 100644 index 0000000..c81f28d --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/README.md @@ -0,0 +1,5 @@ +# @internal/backstage-plugin-plugin-catalog-backend-module-google + +The google backend module for the plugin-catalog plugin. + +_This plugin was created through the Backstage CLI_ diff --git a/src/plugins/plugin-catalog-backend-module-google/package.json b/src/plugins/plugin-catalog-backend-module-google/package.json new file mode 100644 index 0000000..ac15136 --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/package.json @@ -0,0 +1,41 @@ +{ + "name": "@internal/backstage-plugin-catalog-backend-module-google", + "description": "The google backend module for the plugin-catalog plugin.", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.0.0", + "@backstage/catalog-model": "^1.7.2", + "@backstage/config": "^1.3.1", + "@backstage/plugin-catalog-node": "^1.15.0", + "google-auth-library": "^9.15.0", + "googleapis": "^144.0.0" + }, + "devDependencies": { + "@backstage/backend-test-utils": "^1.0.0", + "@backstage/cli": "^0.27.1" + }, + "files": [ + "dist" + ] +} diff --git a/src/plugins/plugin-catalog-backend-module-google/src/index.ts b/src/plugins/plugin-catalog-backend-module-google/src/index.ts new file mode 100644 index 0000000..7def97f --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/src/index.ts @@ -0,0 +1,8 @@ +/***/ +/** + * The google backend module for the plugin-catalog plugin. + * + * @packageDocumentation + */ + +export { pluginCatalogModuleGoogle as default } from './module'; diff --git a/src/plugins/plugin-catalog-backend-module-google/src/module.ts b/src/plugins/plugin-catalog-backend-module-google/src/module.ts new file mode 100644 index 0000000..6169560 --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/src/module.ts @@ -0,0 +1,64 @@ +import { + coreServices, + createBackendModule, + SchedulerServiceTaskRunner, +} from '@backstage/backend-plugin-api'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node/alpha' +import { + GoogleGroupProvider, + GoogleUserProvider, + ProviderConfig +} from './provider' + +export const pluginCatalogModuleGoogle = createBackendModule({ + pluginId: 'catalog', + moduleId: 'google', + register(reg) { + reg.registerInit({ + deps: { + catalog: catalogProcessingExtensionPoint, + logger: coreServices.logger, + rootConfig: coreServices.rootConfig, + scheduler: coreServices.scheduler + }, + async init({ catalog, rootConfig, logger, scheduler }) { + logger.info('Initializing Google User & Group Providers'); + + const config = rootConfig.get('catalog.providers.google') as ProviderConfig | undefined + + // Create a scheduled task runner + const schedule = config?.schedule ? + config?.schedule + : { + initialDelay: { seconds: 0 }, + frequency: { hours: 1 }, + timeout: { seconds: 60 } + } + + const taskRunner: SchedulerServiceTaskRunner = + scheduler.createScheduledTaskRunner(schedule); + + // Initialize the group provider + catalog.addEntityProvider( + GoogleGroupProvider.fromConfig( + rootConfig, + { + logger, + taskRunner + } + ) + ) + // Initialize the user provider + catalog.addEntityProvider( + GoogleUserProvider.fromConfig( + rootConfig, + { + logger, + taskRunner + } + ) + ) + }, + }); + }, +}); diff --git a/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleBaseProvider.test.ts b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleBaseProvider.test.ts new file mode 100644 index 0000000..40b0b21 --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleBaseProvider.test.ts @@ -0,0 +1,78 @@ +import { mockServices } from '@backstage/backend-test-utils' +import { GoogleBaseProvider } from './GoogleBaseProvider' +import * as creds from '../../../../app-config.d/credentials/google-jwt.keys.json' /* eslint @backstage/no-relative-monorepo-imports: off */ + +describe('GoogleUserProvider', () => { + let mockConfig: any + let provider: GoogleBaseProvider + let mockConnection: any + let mockTaskRunner: any + + beforeEach(() => { + mockConnection = { + applyMutation: jest.fn() + } + mockTaskRunner = { + run: jest.fn() + } + + mockConfig = { + auth: { + adminAccountEmail: "tom@serverlessops.io", + clientCredentials: creds + }, + schedule: { + initialDelay: { seconds: 5 }, + frequency: { minutes: 1 }, + timeout: { seconds: 30 }, + } + } + + provider = new GoogleBaseProvider( + mockConfig, + { + logger: mockServices.rootLogger(), + taskRunner: mockTaskRunner + } + ) + }) + + describe('getProviderName()', () => { + describe('should succeed', () => { + test('provider name', () => { + expect(provider.getProviderName()).toBe('google-base') + }) + }) + }) + + describe('getCredentials()', () => { + describe('should succeed', () => { + test('when getting JWT', () => { + const credentials = provider.getCredentials( + mockConfig.auth.adminAccountEmail, + mockConfig.auth.clientCredentials, + [] + ) + expect(credentials).toBeDefined() + }) + }) + }) + + describe('connect()', () => { + describe('should succeed when', () => { + test('connects and runs successfully', async () => { + await provider.connect(mockConnection) + expect(mockTaskRunner.run).toHaveBeenCalled() + }) + }) + }) + + describe('run()', () => { + describe('should succeed when', () => { + test('runs successfully', async () => { + await provider.connect(mockConnection) + expect( async () => await provider.run()).not.toThrow() + }, 10 * 1000) + }) + }) +}) \ No newline at end of file diff --git a/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleBaseProvider.ts b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleBaseProvider.ts new file mode 100644 index 0000000..bb3635f --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleBaseProvider.ts @@ -0,0 +1,83 @@ +import { + EntityProvider, + EntityProviderConnection, +} from '@backstage/plugin-catalog-node' +import { + LoggerService, + SchedulerServiceTaskRunner, + SchedulerServiceTaskScheduleDefinition +} from '@backstage/backend-plugin-api' +import { Config } from '@backstage/config' +import { JWT, JWTInput } from 'google-auth-library' +import { admin_directory_v1 } from 'googleapis' + + +export interface ProviderConfig { + auth: { + adminAccountEmail: string + clientCredentials: JWTInput + } + schedule: SchedulerServiceTaskScheduleDefinition + pageSize?: number +} + +export interface ProviderOptions { + logger: LoggerService + taskRunner: SchedulerServiceTaskRunner +} + + +export class GoogleBaseProvider implements EntityProvider { + readonly providerConfig: ProviderConfig + connection?: EntityProviderConnection + readonly logger: LoggerService + taskRunner: SchedulerServiceTaskRunner + googleAdmin?: admin_directory_v1.Admin + + constructor( + providerConfig: ProviderConfig, + options: ProviderOptions + ) { + this.providerConfig = providerConfig + this.logger = options.logger.child({ target: this.getProviderName() }) + this.taskRunner = options.taskRunner + } + + static fromConfig( + config: Config, + options: ProviderOptions + ): GoogleBaseProvider { + const providerConfig = config.get('catalog.providers.google') as ProviderConfig | undefined + + return new GoogleBaseProvider(providerConfig as ProviderConfig, options) + } + + async connect(connection: EntityProviderConnection): Promise { + this.connection = connection + if (!this.connection) { + throw new Error('Not initialized') + } + + await this.taskRunner.run({ + id: this.getProviderName(), + fn: async () => { + await this.run() + } + }) + } + + async run(): Promise { return } + + getProviderName(): string { + return 'google-base' + } + + getCredentials(adminEmail: string, credentials: JWTInput, scopes: string[]): JWT { + const auth = new JWT({ + subject: adminEmail, + scopes + }) + auth.fromJSON(credentials) + return auth + } +} \ No newline at end of file diff --git a/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleGroupProvider.test.ts b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleGroupProvider.test.ts new file mode 100644 index 0000000..24659d3 --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleGroupProvider.test.ts @@ -0,0 +1,118 @@ +import { mockServices } from '@backstage/backend-test-utils' +import { GoogleGroupProvider, SCOPES } from './GoogleGroupProvider' +import { admin_directory_v1 } from 'googleapis' +import * as creds from '../../../../app-config.d/credentials/google-jwt.keys.json' /* eslint @backstage/no-relative-monorepo-imports: off */ + + +jest.mock('googleapis', () => { + return { + ...jest.requireActual('googleapis') + } +}) + +describe('GoogleGroupProvider', () => { + let mockConfig: any + let provider: GoogleGroupProvider + let mockConnection: any + let mockTaskRunner: any + + beforeEach(() => { + jest.resetAllMocks() + + mockConnection = { + applyMutation: jest.fn() + } + mockTaskRunner = { + run: jest.fn() + } + + mockConfig = { + auth: { + adminAccountEmail: "tom@serverlessops.io", + clientCredentials: creds + }, + schedule: { + initialDelay: { seconds: 5 }, + frequency: { minutes: 1 }, + timeout: { seconds: 30 }, + } + } + + provider = new GoogleGroupProvider( + mockConfig, + { + logger: mockServices.rootLogger(), + taskRunner: mockTaskRunner + } + ) + }) + + describe('getProviderName()', () => { + describe('should succeed', () => { + test('provider name', () => { + expect(provider.getProviderName()).toBe('google-group') + }) + }) + }) + + describe('getCredentials()', () => { + describe('should succeed', () => { + test('when getting JWT', () => { + const credentials = provider.getCredentials( + mockConfig.auth.adminAccountEmail, + mockConfig.auth.clientCredentials, + SCOPES + ) + expect(credentials).toBeDefined() + }) + }) + }) + + describe('listGroups()', () => { + describe('should succeed', () => { + test('when listing groups', async () => { + const groups = await provider.listGroups() + expect(groups.length).toBeGreaterThan(0) + }, 20 * 1000) + + test('when listing groups with multiple pages of results', async () => { + const mockListResponse = jest.fn() + mockListResponse + .mockReturnValueOnce({ data: { groups: [{ id: '1' }], nextPageToken: 'next' }}) + .mockReturnValueOnce({data: { groups: [{ id: '2' }]}}) + const mockAdmin = jest.fn().mockReturnValue({ + groups: { + list: mockListResponse + } + }) + + // Set page size to 1 to force multiple pages of results + provider.providerConfig.pageSize = 1 + provider.googleAdmin = mockAdmin() as unknown as admin_directory_v1.Admin + + const groups = await provider.listGroups() + expect(groups.length).toEqual(2) + expect(mockListResponse).toHaveBeenCalledTimes(2) + }, 20 * 1000) + }) + }) + + describe('connect()', () => { + describe('should succeed when', () => { + test('connects and runs successfully', async () => { + await provider.connect(mockConnection) + expect(mockTaskRunner.run).toHaveBeenCalled() + }) + }) + }) + + describe('run()', () => { + describe('should succeed when', () => { + test('runs successfully', async () => { + await provider.connect(mockConnection) + await provider.run() + expect(mockConnection.applyMutation).toHaveBeenCalled() + }, 20 * 1000) + }) + }) +}) \ No newline at end of file diff --git a/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleGroupProvider.ts b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleGroupProvider.ts new file mode 100644 index 0000000..dd8e0c2 --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleGroupProvider.ts @@ -0,0 +1,120 @@ +import { + ANNOTATION_LOCATION, + ANNOTATION_ORIGIN_LOCATION, + GroupEntityV1alpha1 +} from '@backstage/catalog-model' +import { + EntityProviderConnection, +} from '@backstage/plugin-catalog-node' +import { Config } from '@backstage/config' +import { google, admin_directory_v1 } from 'googleapis' +import { GoogleBaseProvider, ProviderConfig, ProviderOptions } from './GoogleBaseProvider' + + +const PROVIDER_ANNOTATION_LOCATION = 'url:https://admin.googleapis.com/admin/directory/v1/groups' +export const SCOPES = ['https://www.googleapis.com/auth/admin.directory.group.readonly'] + + +export class GoogleGroupProvider extends GoogleBaseProvider { + constructor( + providerConfig: ProviderConfig, + options: ProviderOptions + ) { + super(providerConfig, options) + + const jwt = this.getCredentials( + this.providerConfig.auth.adminAccountEmail, + this.providerConfig.auth.clientCredentials, + SCOPES + ) + this.googleAdmin = google.admin({ version: 'directory_v1', auth: jwt }) + + this.logger.info('Initialized Google Group Provider') + } + + static fromConfig( + config: Config, + options: ProviderOptions + ): GoogleGroupProvider { + const providerConfig = config.get('catalog.providers.google') as ProviderConfig | undefined + + return new GoogleGroupProvider(providerConfig as ProviderConfig, options) + } + + async connect(connection: EntityProviderConnection): Promise { + this.connection = connection + if (!this.connection) { + throw new Error('Not initialized') + } + + await this.taskRunner.run({ + id: this.getProviderName(), + fn: async () => { + await this.run() + } + }) + } + + async listGroups(): Promise { + if (!this.googleAdmin) { + throw new Error('Google Admin not initialized') + } + + let pageToken: string | undefined + let groups: admin_directory_v1.Schema$Group[] = [] + do { + const res = await this.googleAdmin.groups.list({ + customer: 'my_customer', + maxResults: this.providerConfig.pageSize || 200, + pageToken + }) + groups = groups.concat(res.data.groups || []) + pageToken = res.data.nextPageToken || undefined + this.logger.debug(`groups.list() pageToken: ${pageToken}`) + } while (pageToken) + return groups + } + + async run(): Promise { + const groups = await this.listGroups() + + const collectedEntities: GroupEntityV1alpha1[] = groups.map(group => ({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: group.id as string, + description: group.description || undefined, + annotations: { + 'google.com/group-id': group.id as string, + 'google.com/group-kind': group.kind as string, + [ANNOTATION_LOCATION]: PROVIDER_ANNOTATION_LOCATION, + [ANNOTATION_ORIGIN_LOCATION]: PROVIDER_ANNOTATION_LOCATION + } + }, + spec: { + type: 'security-group', + children: [], + profile: { + email: group.email || undefined, + displayName: group.name || undefined, + description: group.description + } + } + })) + + this.logger.info( + `Number of Google groups collected: ${collectedEntities.length}` + ) + await this.connection?.applyMutation({ + type: 'full', + entities: collectedEntities.map(entity => ({ + entity, + locationKey: PROVIDER_ANNOTATION_LOCATION + })) + }) + } + + getProviderName(): string { + return 'google-group' + } +} \ No newline at end of file diff --git a/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleUserProvider.test.ts b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleUserProvider.test.ts new file mode 100644 index 0000000..00553d0 --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleUserProvider.test.ts @@ -0,0 +1,146 @@ +import { mockServices } from '@backstage/backend-test-utils' +import { GoogleUserProvider, SCOPES } from './GoogleUserProvider' +import { admin_directory_v1 } from 'googleapis' +import * as creds from '../../../../app-config.d/credentials/google-jwt.keys.json' /* eslint @backstage/no-relative-monorepo-imports: off */ + +jest.mock('googleapis', () => { + return { + ...jest.requireActual('googleapis') + } +}) + +describe('GoogleUserProvider', () => { + let mockConfig: any + let provider: GoogleUserProvider + let mockConnection: any + let mockTaskRunner: any + + beforeEach(() => { + jest.resetAllMocks() + + mockConnection = { + applyMutation: jest.fn() + } + mockTaskRunner = { + run: jest.fn() + } + + mockConfig = { + auth: { + adminAccountEmail: "tom@serverlessops.io", + clientCredentials: creds + }, + schedule: { + initialDelay: { seconds: 5 }, + frequency: { minutes: 1 }, + timeout: { seconds: 30 }, + } + } + + provider = new GoogleUserProvider( + mockConfig, + { + logger: mockServices.rootLogger(), + taskRunner: mockTaskRunner + } + ) + }) + + describe('getProviderName()', () => { + describe('should succeed', () => { + test('provider name', () => { + expect(provider.getProviderName()).toBe('google-user') + }) + }) + }) + + describe('getCredentials()', () => { + describe('should succeed', () => { + test('when getting JWT', () => { + const credentials = provider.getCredentials( + mockConfig.auth.adminAccountEmail, + mockConfig.auth.clientCredentials, + SCOPES + ) + expect(credentials).toBeDefined() + }) + }) + }) + + describe('getUserGroups()', () => { + describe('should succeed', () => { + test('when listing users groups', async () => { + const users = await provider.getUserGroups('tom@serverlessops.io') + expect(users.length).toBeGreaterThan(0) + }, 20 * 1000) + + test('when listing users groups with multiple pages of results', async () => { + const mockListResponse = jest.fn() + mockListResponse + .mockReturnValueOnce({ data: { groups: [{ id: '1' }], nextPageToken: 'next' } }) + .mockReturnValueOnce({ data: { groups: [{ id: '2' }] } }) + const mockAdmin = jest.fn().mockReturnValue({ + groups: { + list: mockListResponse + } + }) + + // Set page size to 1 to force multiple pages of results + provider.providerConfig.pageSize = 1 + provider.googleAdmin = mockAdmin() as unknown as admin_directory_v1.Admin + + const users = await provider.getUserGroups('tom@serverlessops.io') + expect(users.length).toEqual(2) + expect(mockListResponse).toHaveBeenCalledTimes(2) + }, 20 * 1000) + }) + }) + + describe('listUsers()', () => { + describe('should succeed', () => { + test('when listing users', async () => { + const users = await provider.listUsers() + expect(users.length).toBeGreaterThan(0) + }, 20 * 1000) + + test('when listing users with multiple pages of results', async () => { + const mockListResponse = jest.fn() + mockListResponse + .mockReturnValueOnce({ data: { users: [{ id: '1' }], nextPageToken: 'next' } }) + .mockReturnValueOnce({ data: { users: [{ id: '2' }] } }) + const mockAdmin = jest.fn().mockReturnValue({ + users: { + list: mockListResponse + } + }) + + // Set page size to 1 to force multiple pages of results + provider.providerConfig.pageSize = 1 + provider.googleAdmin = mockAdmin() as unknown as admin_directory_v1.Admin + + const users = await provider.listUsers() + expect(users.length).toEqual(2) + expect(mockListResponse).toHaveBeenCalledTimes(2) + }, 20 * 1000) + }) + }) + + describe('connect()', () => { + describe('should succeed when', () => { + test('connects and runs successfully', async () => { + await provider.connect(mockConnection) + expect(mockTaskRunner.run).toHaveBeenCalled() + }) + }) + }) + + describe('run()', () => { + describe('should succeed when', () => { + test('runs successfully', async () => { + await provider.connect(mockConnection) + await provider.run() + expect(mockConnection.applyMutation).toHaveBeenCalled() + }, 20 * 1000) + }) + }) +}) \ No newline at end of file diff --git a/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleUserProvider.ts b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleUserProvider.ts new file mode 100644 index 0000000..3cfd4dd --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/src/provider/GoogleUserProvider.ts @@ -0,0 +1,148 @@ +import { + ANNOTATION_LOCATION, + ANNOTATION_ORIGIN_LOCATION, + UserEntityV1alpha1 +} from '@backstage/catalog-model' +import { + EntityProviderConnection, +} from '@backstage/plugin-catalog-node' +import { Config } from '@backstage/config' +import { google, admin_directory_v1 } from 'googleapis' +import { GoogleBaseProvider, ProviderConfig, ProviderOptions } from './GoogleBaseProvider' + + +const PROVIDER_ANNOTATION_LOCATION = 'url:https://admin.googleapis.com/admin/directory/v1/users' +export const SCOPES = [ + 'https://www.googleapis.com/auth/admin.directory.user.readonly', + 'https://www.googleapis.com/auth/admin.directory.group.readonly' +] + + +export class GoogleUserProvider extends GoogleBaseProvider { + constructor( + providerConfig: ProviderConfig, + options: ProviderOptions + ) { + super (providerConfig, options) + + const jwt = this.getCredentials( + this.providerConfig.auth.adminAccountEmail, + this.providerConfig.auth.clientCredentials, + SCOPES + ) + this.googleAdmin = google.admin({ version: 'directory_v1', auth: jwt }) + + this.logger.info('Initialized Google User Provider') + } + + static fromConfig( + config: Config, + options: ProviderOptions + ): GoogleUserProvider { + const providerConfig = config.get('catalog.providers.google') as ProviderConfig | undefined + + return new GoogleUserProvider(providerConfig as ProviderConfig, options) + } + + async connect(connection: EntityProviderConnection): Promise { + this.connection = connection + if (!this.connection) { + throw new Error('Not initialized') + } + + await this.taskRunner.run({ + id: this.getProviderName(), + fn: async () => { + await this.run() + } + }) + } + + async getUserGroups(userId: string): Promise { + if ( !this.googleAdmin ) { + throw new Error('Google Admin not initialized') + } + + let pageToken: string | undefined + let groups: admin_directory_v1.Schema$Group[] = [] + do { + const res = await this.googleAdmin.groups.list({ + userKey: userId, + maxResults: this.providerConfig.pageSize || 200, + pageToken + }) + groups = groups.concat(res.data.groups || []) + pageToken = res.data.nextPageToken || undefined + this.logger.debug(`groups.list() pageToken: ${pageToken}`) + } while (pageToken) + return groups + } + + async listUsers(): Promise { + if ( !this.googleAdmin ) { + throw new Error('Google Admin not initialized') + } + + let pageToken: string | undefined + let users: admin_directory_v1.Schema$User[] = [] + do { + const res = await this.googleAdmin.users.list({ + customer: 'my_customer', + viewType: 'domain_public', + maxResults: this.providerConfig.pageSize || 200, + pageToken + }) + users = users.concat(res.data.users || []) + pageToken = res.data.nextPageToken || undefined + this.logger.debug(`users.list() pageToken: ${pageToken}`) + } while(pageToken) + return users + } + + async run(): Promise { + const users = await this.listUsers() + + const collectedEntities: UserEntityV1alpha1[] = await Promise.all(users.map( async (user) => { + const entity: UserEntityV1alpha1 = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: `${(user.name?.givenName as string).toLowerCase()}.${(user.name?.familyName as string).toLowerCase()}`, + annotations: { + 'google.com/user-id': user.id as string, + 'google.com/user-kind': user.kind as string, + [ANNOTATION_LOCATION]: PROVIDER_ANNOTATION_LOCATION, + [ANNOTATION_ORIGIN_LOCATION]: PROVIDER_ANNOTATION_LOCATION + } + }, + spec: { + profile: { + displayName: user.name?.fullName || undefined, + email: user.primaryEmail || undefined, + picture: user.thumbnailPhotoUrl || undefined + } + } + } + + const groups = await this.getUserGroups(user.id as string) + entity.spec.memberOf = groups.map( group => group.id as string) + + return entity + })) + + this.logger.info( + `Number of Google users collected: ${collectedEntities.length}` + ) + await this.connection?.applyMutation({ + type: 'full', + entities: collectedEntities.map(entity => ({ + entity, + locationKey: PROVIDER_ANNOTATION_LOCATION + })) + }) + } + + getProviderName(): string { + return 'google-user' + } +} \ No newline at end of file diff --git a/src/plugins/plugin-catalog-backend-module-google/src/provider/index.ts b/src/plugins/plugin-catalog-backend-module-google/src/provider/index.ts new file mode 100644 index 0000000..6b26db8 --- /dev/null +++ b/src/plugins/plugin-catalog-backend-module-google/src/provider/index.ts @@ -0,0 +1,3 @@ +export { GoogleGroupProvider } from './GoogleGroupProvider' +export { GoogleUserProvider } from './GoogleUserProvider' +export type { ProviderConfig } from './GoogleBaseProvider' diff --git a/src/yarn.lock b/src/yarn.lock index 99f4aae..9412c98 100644 --- a/src/yarn.lock +++ b/src/yarn.lock @@ -2818,6 +2818,43 @@ __metadata: languageName: node linkType: hard +"@backstage/backend-app-api@npm:^1.1.0": + version: 1.1.0 + resolution: "@backstage/backend-app-api@npm:1.1.0" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.1.0" + "@backstage/cli-common": "npm:^0.1.15" + "@backstage/config": "npm:^1.3.1" + "@backstage/config-loader": "npm:^1.9.3" + "@backstage/errors": "npm:^1.2.6" + "@backstage/plugin-auth-node": "npm:^0.5.5" + "@backstage/plugin-permission-node": "npm:^0.8.6" + "@backstage/types": "npm:^1.2.0" + "@manypkg/get-packages": "npm:^1.1.3" + compression: "npm:^1.7.4" + cookie: "npm:^0.7.0" + cors: "npm:^2.8.5" + helmet: "npm:^6.0.0" + jose: "npm:^5.0.0" + knex: "npm:^3.0.0" + lodash: "npm:^4.17.21" + logform: "npm:^2.3.2" + luxon: "npm:^3.0.0" + minimatch: "npm:^9.0.0" + minimist: "npm:^1.2.5" + morgan: "npm:^1.10.0" + node-forge: "npm:^1.3.1" + path-to-regexp: "npm:^8.0.0" + selfsigned: "npm:^2.0.0" + stoppable: "npm:^1.1.0" + triple-beam: "npm:^1.4.1" + uuid: "npm:^11.0.0" + winston: "npm:^3.2.1" + winston-transport: "npm:^4.5.0" + checksum: 10c0/9cdd440e4855ef784f6c68c33673cd241211c0aceadca6f948cff8f93e5a1ab851ecfc592d503f0aed5ebedf3eb2ec8ef258b0a9254a40e1142b805365a27e93 + languageName: node + linkType: hard + "@backstage/backend-common@npm:^0.25.0": version: 0.25.0 resolution: "@backstage/backend-common@npm:0.25.0" @@ -3129,6 +3166,89 @@ __metadata: languageName: node linkType: hard +"@backstage/backend-defaults@npm:^0.6.0": + version: 0.6.1 + resolution: "@backstage/backend-defaults@npm:0.6.1" + dependencies: + "@aws-sdk/abort-controller": "npm:^3.347.0" + "@aws-sdk/client-codecommit": "npm:^3.350.0" + "@aws-sdk/client-s3": "npm:^3.350.0" + "@aws-sdk/credential-providers": "npm:^3.350.0" + "@aws-sdk/types": "npm:^3.347.0" + "@azure/identity": "npm:^4.0.0" + "@azure/storage-blob": "npm:^12.5.0" + "@backstage/backend-app-api": "npm:^1.1.0" + "@backstage/backend-dev-utils": "npm:^0.1.5" + "@backstage/backend-plugin-api": "npm:^1.1.0" + "@backstage/cli-common": "npm:^0.1.15" + "@backstage/cli-node": "npm:^0.2.11" + "@backstage/config": "npm:^1.3.1" + "@backstage/config-loader": "npm:^1.9.4" + "@backstage/errors": "npm:^1.2.6" + "@backstage/integration": "npm:^1.16.0" + "@backstage/integration-aws-node": "npm:^0.1.14" + "@backstage/plugin-auth-node": "npm:^0.5.5" + "@backstage/plugin-events-node": "npm:^0.4.6" + "@backstage/plugin-permission-node": "npm:^0.8.6" + "@backstage/types": "npm:^1.2.0" + "@google-cloud/storage": "npm:^7.0.0" + "@keyv/memcache": "npm:^2.0.1" + "@keyv/redis": "npm:^4.0.1" + "@manypkg/get-packages": "npm:^1.1.3" + "@octokit/rest": "npm:^19.0.3" + "@opentelemetry/api": "npm:^1.9.0" + "@types/cors": "npm:^2.8.6" + "@types/express": "npm:^4.17.6" + archiver: "npm:^7.0.0" + base64-stream: "npm:^1.0.0" + better-sqlite3: "npm:^11.0.0" + compression: "npm:^1.7.4" + concat-stream: "npm:^2.0.0" + cookie: "npm:^0.7.0" + cors: "npm:^2.8.5" + cron: "npm:^3.0.0" + express: "npm:^4.17.1" + express-promise-router: "npm:^4.1.0" + fs-extra: "npm:^11.2.0" + git-url-parse: "npm:^15.0.0" + helmet: "npm:^6.0.0" + isomorphic-git: "npm:^1.23.0" + jose: "npm:^5.0.0" + keyv: "npm:^5.2.1" + knex: "npm:^3.0.0" + lodash: "npm:^4.17.21" + logform: "npm:^2.3.2" + luxon: "npm:^3.0.0" + minimatch: "npm:^9.0.0" + minimist: "npm:^1.2.5" + mysql2: "npm:^3.0.0" + node-fetch: "npm:^2.7.0" + node-forge: "npm:^1.3.1" + p-limit: "npm:^3.1.0" + p-throttle: "npm:^4.1.1" + path-to-regexp: "npm:^8.0.0" + pg: "npm:^8.11.3" + pg-connection-string: "npm:^2.3.0" + pg-format: "npm:^1.0.4" + raw-body: "npm:^2.4.1" + selfsigned: "npm:^2.0.0" + tar: "npm:^6.1.12" + triple-beam: "npm:^1.4.1" + uuid: "npm:^11.0.0" + winston: "npm:^3.2.1" + winston-transport: "npm:^4.5.0" + yauzl: "npm:^3.0.0" + yn: "npm:^4.0.0" + zod: "npm:^3.22.4" + peerDependencies: + "@google-cloud/cloud-sql-connector": ^1.4.0 + peerDependenciesMeta: + "@google-cloud/cloud-sql-connector": + optional: true + checksum: 10c0/e6a21146ad81aa2f590cbd3376a8cfe0dcf5c10d174f937e702bd392788dd6ab3dade8691a992f719210356a59338cc1ed9cca109bb9ea0b9d0c83d969b787db + languageName: node + linkType: hard + "@backstage/backend-dev-utils@npm:^0.1.5": version: 0.1.5 resolution: "@backstage/backend-dev-utils@npm:0.1.5" @@ -3218,6 +3338,61 @@ __metadata: languageName: node linkType: hard +"@backstage/backend-plugin-api@npm:^1.1.0": + version: 1.1.0 + resolution: "@backstage/backend-plugin-api@npm:1.1.0" + dependencies: + "@backstage/cli-common": "npm:^0.1.15" + "@backstage/config": "npm:^1.3.1" + "@backstage/errors": "npm:^1.2.6" + "@backstage/plugin-auth-node": "npm:^0.5.5" + "@backstage/plugin-permission-common": "npm:^0.8.3" + "@backstage/types": "npm:^1.2.0" + "@types/express": "npm:^4.17.6" + "@types/luxon": "npm:^3.0.0" + knex: "npm:^3.0.0" + luxon: "npm:^3.0.0" + checksum: 10c0/61f30fbbad6acb2869105ef7e49895d2a0e0ef0f601d3deb9dfa438cd46f37385e2df6cf5bb9208d43b069be949624e7ed8bd11559cd3653627b4db053bfd518 + languageName: node + linkType: hard + +"@backstage/backend-test-utils@npm:^1.0.0": + version: 1.2.0 + resolution: "@backstage/backend-test-utils@npm:1.2.0" + dependencies: + "@backstage/backend-app-api": "npm:^1.1.0" + "@backstage/backend-defaults": "npm:^0.6.0" + "@backstage/backend-plugin-api": "npm:^1.1.0" + "@backstage/config": "npm:^1.3.1" + "@backstage/errors": "npm:^1.2.6" + "@backstage/plugin-auth-node": "npm:^0.5.5" + "@backstage/plugin-events-node": "npm:^0.4.6" + "@backstage/types": "npm:^1.2.0" + "@keyv/memcache": "npm:^2.0.1" + "@keyv/redis": "npm:^4.0.1" + "@types/express": "npm:^4.17.6" + "@types/express-serve-static-core": "npm:^4.17.5" + "@types/keyv": "npm:^4.2.0" + "@types/qs": "npm:^6.9.6" + better-sqlite3: "npm:^11.0.0" + cookie: "npm:^0.7.0" + express: "npm:^4.17.1" + fs-extra: "npm:^11.0.0" + keyv: "npm:^5.2.1" + knex: "npm:^3.0.0" + mysql2: "npm:^3.0.0" + pg: "npm:^8.11.3" + pg-connection-string: "npm:^2.3.0" + testcontainers: "npm:^10.0.0" + textextensions: "npm:^5.16.0" + uuid: "npm:^11.0.0" + yn: "npm:^4.0.0" + peerDependencies: + "@types/jest": "*" + checksum: 10c0/767db134c1287200528116bde5eaea3a26e1bde011d789b16ccddad8e1ba8bdab6f1e77f1a22627386904377eb10bcbddf30bcb895af426135b36ce3ab82a59f + languageName: node + linkType: hard + "@backstage/backend-test-utils@npm:^1.0.2": version: 1.0.2 resolution: "@backstage/backend-test-utils@npm:1.0.2" @@ -3292,6 +3467,18 @@ __metadata: languageName: node linkType: hard +"@backstage/catalog-client@npm:^1.9.0": + version: 1.9.0 + resolution: "@backstage/catalog-client@npm:1.9.0" + dependencies: + "@backstage/catalog-model": "npm:^1.7.2" + "@backstage/errors": "npm:^1.2.6" + cross-fetch: "npm:^4.0.0" + uri-template: "npm:^2.0.0" + checksum: 10c0/25ad60e614ceb1d6bfe288a2749ef76fa73a80fb2152458e9142729c57cf9890b41f2b904d64cb5d291323919ac96c4edaddb4208a42d14d414dddd10dde4db1 + languageName: node + linkType: hard + "@backstage/catalog-model@npm:^1.7.0": version: 1.7.0 resolution: "@backstage/catalog-model@npm:1.7.0" @@ -3316,6 +3503,18 @@ __metadata: languageName: node linkType: hard +"@backstage/catalog-model@npm:^1.7.2": + version: 1.7.2 + resolution: "@backstage/catalog-model@npm:1.7.2" + dependencies: + "@backstage/errors": "npm:^1.2.6" + "@backstage/types": "npm:^1.2.0" + ajv: "npm:^8.10.0" + lodash: "npm:^4.17.21" + checksum: 10c0/9cc2d512110a63b355970d4068e06206070e3e3195109b84b05ef3b9ea3e151084e441bed268ebaf647eb12b8a1f2a9003e54ff440b1b8ab8f8630a5538cfd69 + languageName: node + linkType: hard + "@backstage/cli-common@npm:^0.1.14": version: 0.1.14 resolution: "@backstage/cli-common@npm:0.1.14" @@ -3323,6 +3522,29 @@ __metadata: languageName: node linkType: hard +"@backstage/cli-common@npm:^0.1.15": + version: 0.1.15 + resolution: "@backstage/cli-common@npm:0.1.15" + checksum: 10c0/6155d7343814dbe1bc84073d5cdf73e00f379ffc7880a166ad8843443e7dedbe0887a389df5010b909832e8f232d4283a81b2abbda992130a865286445643ff9 + languageName: node + linkType: hard + +"@backstage/cli-node@npm:^0.2.11": + version: 0.2.11 + resolution: "@backstage/cli-node@npm:0.2.11" + dependencies: + "@backstage/cli-common": "npm:^0.1.15" + "@backstage/errors": "npm:^1.2.6" + "@backstage/types": "npm:^1.2.0" + "@manypkg/get-packages": "npm:^1.1.3" + "@yarnpkg/parsers": "npm:^3.0.0" + fs-extra: "npm:^11.2.0" + semver: "npm:^7.5.3" + zod: "npm:^3.22.4" + checksum: 10c0/d21f4d5e16c3876efef6039cc6135ba701fe18606982f17f6490cb5bf3a3a84ea5673f1fd064f8a778485bb7c13ea881f0f768d2a0b7e2f7045bec11c392279c + languageName: node + linkType: hard + "@backstage/cli-node@npm:^0.2.8": version: 0.2.8 resolution: "@backstage/cli-node@npm:0.2.8" @@ -3516,6 +3738,29 @@ __metadata: languageName: node linkType: hard +"@backstage/config-loader@npm:^1.9.3, @backstage/config-loader@npm:^1.9.4": + version: 1.9.4 + resolution: "@backstage/config-loader@npm:1.9.4" + dependencies: + "@backstage/cli-common": "npm:^0.1.15" + "@backstage/config": "npm:^1.3.1" + "@backstage/errors": "npm:^1.2.6" + "@backstage/types": "npm:^1.2.0" + "@types/json-schema": "npm:^7.0.6" + ajv: "npm:^8.10.0" + chokidar: "npm:^3.5.2" + fs-extra: "npm:^11.2.0" + json-schema: "npm:^0.4.0" + json-schema-merge-allof: "npm:^0.8.1" + json-schema-traverse: "npm:^1.0.0" + lodash: "npm:^4.17.21" + minimist: "npm:^1.2.5" + typescript-json-schema: "npm:^0.65.0" + yaml: "npm:^2.0.0" + checksum: 10c0/3a5fb98c459b207a9ea639dea479258afcf06c875564a665d62257f7df01f17f33f131425dc4d9d77d2012683300825822c1096ad1cc670fec6b072fdf2f6ab5 + languageName: node + linkType: hard + "@backstage/config@npm:^1.2.0": version: 1.2.0 resolution: "@backstage/config@npm:1.2.0" @@ -3537,6 +3782,17 @@ __metadata: languageName: node linkType: hard +"@backstage/config@npm:^1.3.1": + version: 1.3.1 + resolution: "@backstage/config@npm:1.3.1" + dependencies: + "@backstage/errors": "npm:^1.2.6" + "@backstage/types": "npm:^1.2.0" + ms: "npm:^2.1.3" + checksum: 10c0/f9c519b57f0c6590ad6f2a6619bf418d3e64a4cf4cf05b357cf109b64e25bb479923665dd88937cfb2ec1cc87d5ede784690df7a9ee4ed99e5907674023adc60 + languageName: node + linkType: hard + "@backstage/core-app-api@npm:^1.15.0": version: 1.15.0 resolution: "@backstage/core-app-api@npm:1.15.0" @@ -3950,6 +4206,16 @@ __metadata: languageName: node linkType: hard +"@backstage/errors@npm:^1.2.6": + version: 1.2.6 + resolution: "@backstage/errors@npm:1.2.6" + dependencies: + "@backstage/types": "npm:^1.2.0" + serialize-error: "npm:^8.0.1" + checksum: 10c0/c563debf11cd89fbbc246a3d8050979a2a52bce625bbb2df77673be3694ae3669c313029f95272446d73c48b8f86b496d1b63e5d929d3fc44a1fd61fcc081db7 + languageName: node + linkType: hard + "@backstage/eslint-plugin@npm:^0.1.9": version: 0.1.9 resolution: "@backstage/eslint-plugin@npm:0.1.9" @@ -4189,6 +4455,21 @@ __metadata: languageName: node linkType: hard +"@backstage/integration-aws-node@npm:^0.1.14": + version: 0.1.14 + resolution: "@backstage/integration-aws-node@npm:0.1.14" + dependencies: + "@aws-sdk/client-sts": "npm:^3.350.0" + "@aws-sdk/credential-provider-node": "npm:^3.350.0" + "@aws-sdk/credential-providers": "npm:^3.350.0" + "@aws-sdk/types": "npm:^3.347.0" + "@aws-sdk/util-arn-parser": "npm:^3.310.0" + "@backstage/config": "npm:^1.3.1" + "@backstage/errors": "npm:^1.2.6" + checksum: 10c0/60958879b8d011227a5a2937d2cb30a2ad1b7b74e2d15bae1abd69f81d892cb7eed6d01870a0f645700de6eb8be1022fd893085339f48e54e8c5c559579e69bc + languageName: node + linkType: hard + "@backstage/integration-react@npm:^1.1.31, @backstage/integration-react@npm:^1.1.32": version: 1.1.32 resolution: "@backstage/integration-react@npm:1.1.32" @@ -4300,6 +4581,24 @@ __metadata: languageName: node linkType: hard +"@backstage/integration@npm:^1.16.0": + version: 1.16.0 + resolution: "@backstage/integration@npm:1.16.0" + dependencies: + "@azure/identity": "npm:^4.0.0" + "@azure/storage-blob": "npm:^12.5.0" + "@backstage/config": "npm:^1.3.1" + "@backstage/errors": "npm:^1.2.6" + "@octokit/auth-app": "npm:^4.0.0" + "@octokit/rest": "npm:^19.0.3" + cross-fetch: "npm:^4.0.0" + git-url-parse: "npm:^15.0.0" + lodash: "npm:^4.17.21" + luxon: "npm:^3.0.0" + checksum: 10c0/b838145d3407a55a0a71d50dede17ab080bfd472e4c7cac2b9b13bc9d3bfe4b2f2a9fb57b58ff5f2b23102c25816591e3479a3a90f517b1af250d9e8de32c475 + languageName: node + linkType: hard + "@backstage/plugin-api-docs@npm:^0.11.9": version: 0.11.10 resolution: "@backstage/plugin-api-docs@npm:0.11.10" @@ -4818,6 +5117,31 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-auth-node@npm:^0.5.5": + version: 0.5.5 + resolution: "@backstage/plugin-auth-node@npm:0.5.5" + dependencies: + "@backstage/backend-common": "npm:^0.25.0" + "@backstage/backend-plugin-api": "npm:^1.1.0" + "@backstage/catalog-client": "npm:^1.9.0" + "@backstage/catalog-model": "npm:^1.7.2" + "@backstage/config": "npm:^1.3.1" + "@backstage/errors": "npm:^1.2.6" + "@backstage/types": "npm:^1.2.0" + "@types/express": "npm:^4.17.6" + "@types/passport": "npm:^1.0.3" + express: "npm:^4.17.1" + jose: "npm:^5.0.0" + lodash: "npm:^4.17.21" + passport: "npm:^0.7.0" + winston: "npm:^3.2.1" + zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.21.4" + zod-validation-error: "npm:^3.4.0" + checksum: 10c0/d35018fda29f79283c828b0a0d24f8ff5e6208230fdfb57dc9a377010334c128c1ad0bb3e1a7cfc85c08ffdb685fe882678a883b184c42d5d5be4da1e700fe6b + languageName: node + linkType: hard + "@backstage/plugin-auth-react@npm:^0.1.6": version: 0.1.6 resolution: "@backstage/plugin-auth-react@npm:0.1.6" @@ -5022,6 +5346,17 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-catalog-common@npm:^1.1.2": + version: 1.1.2 + resolution: "@backstage/plugin-catalog-common@npm:1.1.2" + dependencies: + "@backstage/catalog-model": "npm:^1.7.2" + "@backstage/plugin-permission-common": "npm:^0.8.3" + "@backstage/plugin-search-common": "npm:^1.2.16" + checksum: 10c0/448b1bb587ddb76e8ab2c6066bf9658efb6bf6262ffd95e2fee3defcb2939480a30b2f0585c01c3c03e3ec80160a436ee77cf429316d57db959f4a7dd2841119 + languageName: node + linkType: hard + "@backstage/plugin-catalog-graph@npm:^0.4.13": version: 0.4.13 resolution: "@backstage/plugin-catalog-graph@npm:0.4.13" @@ -5150,6 +5485,22 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-catalog-node@npm:^1.15.0": + version: 1.15.0 + resolution: "@backstage/plugin-catalog-node@npm:1.15.0" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.1.0" + "@backstage/catalog-client": "npm:^1.9.0" + "@backstage/catalog-model": "npm:^1.7.2" + "@backstage/errors": "npm:^1.2.6" + "@backstage/plugin-catalog-common": "npm:^1.1.2" + "@backstage/plugin-permission-common": "npm:^0.8.3" + "@backstage/plugin-permission-node": "npm:^0.8.6" + "@backstage/types": "npm:^1.2.0" + checksum: 10c0/cf9725e97fd35d021d4c2f87eaf819535a6bc31cb118d13746489588b6d74fa601cb669a19e60577bfa073c8b08d58c1ffeeb76d57d36b2aa806cb2359880236 + languageName: node + linkType: hard + "@backstage/plugin-catalog-react@npm:^1.13.0, @backstage/plugin-catalog-react@npm:^1.13.1": version: 1.13.1 resolution: "@backstage/plugin-catalog-react@npm:1.13.1" @@ -5427,6 +5778,19 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-events-node@npm:^0.4.6": + version: 0.4.6 + resolution: "@backstage/plugin-events-node@npm:0.4.6" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.1.0" + "@backstage/errors": "npm:^1.2.6" + "@backstage/types": "npm:^1.2.0" + cross-fetch: "npm:^4.0.0" + uri-template: "npm:^2.0.0" + checksum: 10c0/8da8b05d19d7738eea4976cdcb06e693857798c474d9fcd6c783814f06663f5ea6377bb8e852a35c371b3a42fa851d04a401a3ba494443b68bd254cfeecdbd25 + languageName: node + linkType: hard + "@backstage/plugin-org@npm:^0.6.29": version: 0.6.30 resolution: "@backstage/plugin-org@npm:0.6.30" @@ -5551,6 +5915,21 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-permission-common@npm:^0.8.3": + version: 0.8.3 + resolution: "@backstage/plugin-permission-common@npm:0.8.3" + dependencies: + "@backstage/config": "npm:^1.3.1" + "@backstage/errors": "npm:^1.2.6" + "@backstage/types": "npm:^1.2.0" + cross-fetch: "npm:^4.0.0" + uuid: "npm:^11.0.0" + zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.20.4" + checksum: 10c0/99a36a8566d4ad292839d78bea68bade9daddf3993572dca2fb81f5269a396d652181d3ed47fccf03d328008a8d518722ddced779db1e39c6b07ef16a584af57 + languageName: node + linkType: hard + "@backstage/plugin-permission-node@npm:^0.8.3": version: 0.8.3 resolution: "@backstage/plugin-permission-node@npm:0.8.3" @@ -5589,6 +5968,25 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-permission-node@npm:^0.8.6": + version: 0.8.6 + resolution: "@backstage/plugin-permission-node@npm:0.8.6" + dependencies: + "@backstage/backend-common": "npm:^0.25.0" + "@backstage/backend-plugin-api": "npm:^1.1.0" + "@backstage/config": "npm:^1.3.1" + "@backstage/errors": "npm:^1.2.6" + "@backstage/plugin-auth-node": "npm:^0.5.5" + "@backstage/plugin-permission-common": "npm:^0.8.3" + "@types/express": "npm:^4.17.6" + express: "npm:^4.17.1" + express-promise-router: "npm:^4.1.0" + zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.20.4" + checksum: 10c0/d006577d1ff6f0b86b0f78699a647d9b9ccde3ec22562688805d5174806c749aeeb6e9114fd1b867a19a944d99a042478d66d3aa713cbb719db81f8c3dc19f03 + languageName: node + linkType: hard + "@backstage/plugin-permission-react@npm:^0.4.26": version: 0.4.26 resolution: "@backstage/plugin-permission-react@npm:0.4.26" @@ -6332,6 +6730,16 @@ __metadata: languageName: node linkType: hard +"@backstage/plugin-search-common@npm:^1.2.16": + version: 1.2.16 + resolution: "@backstage/plugin-search-common@npm:1.2.16" + dependencies: + "@backstage/plugin-permission-common": "npm:^0.8.3" + "@backstage/types": "npm:^1.2.0" + checksum: 10c0/e47bbf56922a3422c1768bc200df4b2030438ed7178208fafb514ece28967953f22789d4e29750ba3663a49fd4101d17aebd7b1f49e28e35df7e5d9fbc7c6039 + languageName: node + linkType: hard + "@backstage/plugin-search-react@npm:^1.8.0": version: 1.8.0 resolution: "@backstage/plugin-search-react@npm:1.8.0" @@ -8307,6 +8715,21 @@ __metadata: languageName: node linkType: hard +"@internal/backstage-plugin-catalog-backend-module-google@workspace:^, @internal/backstage-plugin-catalog-backend-module-google@workspace:plugins/plugin-catalog-backend-module-google": + version: 0.0.0-use.local + resolution: "@internal/backstage-plugin-catalog-backend-module-google@workspace:plugins/plugin-catalog-backend-module-google" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.0.0" + "@backstage/backend-test-utils": "npm:^1.0.0" + "@backstage/catalog-model": "npm:^1.7.2" + "@backstage/cli": "npm:^0.27.1" + "@backstage/config": "npm:^1.3.1" + "@backstage/plugin-catalog-node": "npm:^1.15.0" + google-auth-library: "npm:^9.15.0" + googleapis: "npm:^144.0.0" + languageName: unknown + linkType: soft + "@internal/backstage-plugin-catalog-backend-module-serverlessops-catalog@workspace:^, @internal/backstage-plugin-catalog-backend-module-serverlessops-catalog@workspace:plugins/catalog-backend-module-serverlessops-catalog": version: 0.0.0-use.local resolution: "@internal/backstage-plugin-catalog-backend-module-serverlessops-catalog@workspace:plugins/catalog-backend-module-serverlessops-catalog" @@ -8848,6 +9271,17 @@ __metadata: languageName: node linkType: hard +"@keyv/memcache@npm:^2.0.1": + version: 2.0.1 + resolution: "@keyv/memcache@npm:2.0.1" + dependencies: + "@keyv/serialize": "npm:*" + buffer: "npm:^6.0.3" + memjs: "npm:^1.3.2" + checksum: 10c0/86a47984717f0cba79b9ab233cefd6dd5a2cc116b86483f0958ca52462c62c4142c793d740731bef154c5c88c014dd4f7faad4da6554547ab258ebbe040b5a12 + languageName: node + linkType: hard + "@keyv/redis@npm:^2.5.3": version: 2.8.5 resolution: "@keyv/redis@npm:2.8.5" @@ -8857,7 +9291,18 @@ __metadata: languageName: node linkType: hard -"@keyv/serialize@npm:*": +"@keyv/redis@npm:^4.0.1": + version: 4.1.0 + resolution: "@keyv/redis@npm:4.1.0" + dependencies: + cluster-key-slot: "npm:^1.1.2" + keyv: "npm:^5.2.1" + redis: "npm:^4.7.0" + checksum: 10c0/8edef546342096941206ed9b03589c87b079a645b25c462a03d47fb07b5414eda088b33bbbeb4533a8085362ef65c797ff31f971221719d363cb9b390be3df0e + languageName: node + linkType: hard + +"@keyv/serialize@npm:*, @keyv/serialize@npm:^1.0.1": version: 1.0.1 resolution: "@keyv/serialize@npm:1.0.1" dependencies: @@ -10278,7 +10723,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.4.0": +"@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.4.0, @opentelemetry/api@npm:^1.9.0": version: 1.9.0 resolution: "@opentelemetry/api@npm:1.9.0" checksum: 10c0/9aae2fe6e8a3a3eeb6c1fdef78e1939cf05a0f37f8a4fae4d6bf2e09eb1e06f966ece85805626e01ba5fab48072b94f19b835449e58b6d26720ee19a58298add @@ -10979,6 +11424,62 @@ __metadata: languageName: node linkType: hard +"@redis/bloom@npm:1.2.0": + version: 1.2.0 + resolution: "@redis/bloom@npm:1.2.0" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/7dde8e67188164e96226c8a5c78ebd2801f1662947371e78fb95fb180c1e9ddff8d237012eb5e9182775be61cb546f67f759927cdaee0d178d863ee290e1fb27 + languageName: node + linkType: hard + +"@redis/client@npm:1.6.0": + version: 1.6.0 + resolution: "@redis/client@npm:1.6.0" + dependencies: + cluster-key-slot: "npm:1.1.2" + generic-pool: "npm:3.9.0" + yallist: "npm:4.0.0" + checksum: 10c0/c80a01b4f72d32284515dac6d1aefe0e9c881d08b8db33281f87b51650c1c116b18074a29ca81599d15dccb37b29eef9b26a75a5755150ae27d163e680c34bf6 + languageName: node + linkType: hard + +"@redis/graph@npm:1.1.1": + version: 1.1.1 + resolution: "@redis/graph@npm:1.1.1" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/64199db2cb3669c4911af8aba3b7116c4c2c1df37ca74b2a65555e62c863935a0cea74bc41bd92acf2e551074eb2a30c75f54a9f439b40e0f9bb67fc5fb66614 + languageName: node + linkType: hard + +"@redis/json@npm:1.0.7": + version: 1.0.7 + resolution: "@redis/json@npm:1.0.7" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/cef473711d66f7568a16edbd728acca7d237cfeaa15e0326b5b628dfab4afc0c76c7354e7f8efad6ecc64a1cb774e4aa060ee46497b633e18ba0a2f0aace1cc4 + languageName: node + linkType: hard + +"@redis/search@npm:1.2.0": + version: 1.2.0 + resolution: "@redis/search@npm:1.2.0" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/01d57ac10d2c5698e04e4a2f945440db3087e8834643ca950c099879dbcd77526604ca6f5c2ee883dfd4b337b0a24cb7d81ac56845aa83f89a4f161362a08dc6 + languageName: node + linkType: hard + +"@redis/time-series@npm:1.1.0": + version: 1.1.0 + resolution: "@redis/time-series@npm:1.1.0" + peerDependencies: + "@redis/client": ^1.0.0 + checksum: 10c0/503d0d5cbc9113d26666bb7b4dea57619badbcdfeee0369abf647250f26c5482ed5827c83f88f9f0cf22e021e3e7cb562459669d733fac05652972e208d6ba0f + languageName: node + linkType: hard + "@remix-run/router@npm:1.20.0": version: 1.20.0 resolution: "@remix-run/router@npm:1.20.0" @@ -15793,6 +16294,7 @@ __metadata: "@backstage/plugin-search-backend-module-techdocs": "npm:^0.2.2" "@backstage/plugin-search-backend-node": "npm:^1.3.2" "@backstage/plugin-techdocs-backend": "npm:^1.10.13" + "@internal/backstage-plugin-catalog-backend-module-google": "workspace:^" "@internal/backstage-plugin-catalog-backend-module-serverlessops-catalog": "workspace:^" "@internal/backstage-plugin-scaffolder-backend-module-serverlessops": "workspace:^" "@types/express": "npm:^4.17.6" @@ -16793,7 +17295,7 @@ __metadata: languageName: node linkType: hard -"cluster-key-slot@npm:^1.1.0": +"cluster-key-slot@npm:1.1.2, cluster-key-slot@npm:^1.1.0, cluster-key-slot@npm:^1.1.2": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2" checksum: 10c0/d7d39ca28a8786e9e801eeb8c770e3c3236a566625d7299a47bb71113fb2298ce1039596acb82590e598c52dbc9b1f088c8f587803e697cb58e1867a95ff94d3 @@ -20681,7 +21183,7 @@ __metadata: languageName: node linkType: hard -"gaxios@npm:^6.0.0, gaxios@npm:^6.0.2, gaxios@npm:^6.1.1": +"gaxios@npm:^6.0.0, gaxios@npm:^6.0.2, gaxios@npm:^6.0.3, gaxios@npm:^6.1.1": version: 6.7.1 resolution: "gaxios@npm:6.7.1" dependencies: @@ -20722,6 +21224,13 @@ __metadata: languageName: node linkType: hard +"generic-pool@npm:3.9.0": + version: 3.9.0 + resolution: "generic-pool@npm:3.9.0" + checksum: 10c0/6b314d0d71170d5cbaf7162c423f53f8d6556b2135626a65bcdc03c089840b0a2f59eeb2d907939b8200e945eaf71ceb6630426f22d2128a1d242aec4b232aa7 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -21049,6 +21558,20 @@ __metadata: languageName: node linkType: hard +"google-auth-library@npm:^9.15.0, google-auth-library@npm:^9.7.0": + version: 9.15.0 + resolution: "google-auth-library@npm:9.15.0" + dependencies: + base64-js: "npm:^1.3.0" + ecdsa-sig-formatter: "npm:^1.0.11" + gaxios: "npm:^6.1.1" + gcp-metadata: "npm:^6.1.0" + gtoken: "npm:^7.0.0" + jws: "npm:^4.0.0" + checksum: 10c0/f5a9a46e939147b181bac9b254f11dd8c2d05c15a65c9d3f2180252bef21c12af37d9893bc3caacafd226d6531a960535dbb5222ef869143f393c6a97639cc06 + languageName: node + linkType: hard + "google-gax@npm:^4.3.3": version: 4.4.1 resolution: "google-gax@npm:4.4.1" @@ -21069,6 +21592,30 @@ __metadata: languageName: node linkType: hard +"googleapis-common@npm:^7.0.0": + version: 7.2.0 + resolution: "googleapis-common@npm:7.2.0" + dependencies: + extend: "npm:^3.0.2" + gaxios: "npm:^6.0.3" + google-auth-library: "npm:^9.7.0" + qs: "npm:^6.7.0" + url-template: "npm:^2.0.8" + uuid: "npm:^9.0.0" + checksum: 10c0/cbbce900582a66c28bb8ccde631bc08202c6fb2e591932b981a23b437b074150051b966d3ad67bcb4b06b4ff5bbbfd8524ac5ca6f7b77b8790f417924bec1f3c + languageName: node + linkType: hard + +"googleapis@npm:^144.0.0": + version: 144.0.0 + resolution: "googleapis@npm:144.0.0" + dependencies: + google-auth-library: "npm:^9.0.0" + googleapis-common: "npm:^7.0.0" + checksum: 10c0/a5ad4c5be32817f7960fac2aa52b1bbc658242ed1874fb08ed84dbdf36b9fa401077e6e425928e453629d14b1f0d54ee31a1dc0959705465b489f31bd3f36f4a + languageName: node + linkType: hard + "gopd@npm:^1.0.1": version: 1.0.1 resolution: "gopd@npm:1.0.1" @@ -24059,6 +24606,15 @@ __metadata: languageName: node linkType: hard +"keyv@npm:^5.2.1": + version: 5.2.2 + resolution: "keyv@npm:5.2.2" + dependencies: + "@keyv/serialize": "npm:^1.0.1" + checksum: 10c0/6f9cc97c9ecacb90304fd15be7f829c50b944748d644319931ca718828df9fd1eb76ed963be7996fc249fab22230d319f61d51ee63b04b384186a6b3fe032e06 + languageName: node + linkType: hard + "kind-of@npm:^6.0.2": version: 6.0.3 resolution: "kind-of@npm:6.0.3" @@ -26864,6 +27420,13 @@ __metadata: languageName: node linkType: hard +"p-throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "p-throttle@npm:4.1.1" + checksum: 10c0/c4bfdcd0318d704b446a7af59dd8e0e32e37ba3d9841dd8dfced1c09742bc2f7a95bc0fcf4072030c62abf4533a9a2ef2954e559462052c5f406ae03d195925a + languageName: node + linkType: hard + "p-timeout@npm:^3.2.0": version: 3.2.0 resolution: "p-timeout@npm:3.2.0" @@ -28384,6 +28947,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.7.0": + version: 6.13.1 + resolution: "qs@npm:6.13.1" + dependencies: + side-channel: "npm:^1.0.6" + checksum: 10c0/5ef527c0d62ffca5501322f0832d800ddc78eeb00da3b906f1b260ca0492721f8cdc13ee4b8fd8ac314a6ec37b948798c7b603ccc167e954088df392092f160c + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3" @@ -29224,6 +29796,20 @@ __metadata: languageName: node linkType: hard +"redis@npm:^4.7.0": + version: 4.7.0 + resolution: "redis@npm:4.7.0" + dependencies: + "@redis/bloom": "npm:1.2.0" + "@redis/client": "npm:1.6.0" + "@redis/graph": "npm:1.1.1" + "@redis/json": "npm:1.0.7" + "@redis/search": "npm:1.2.0" + "@redis/time-series": "npm:1.1.0" + checksum: 10c0/a05632a58adbcaa4566238073cd6d00ed008522d2ef015a31aaef200c184a4eff4fa007c514eda91dda1e1205350b5901d0c7b58824dbfa593feb81a0087bf4d + languageName: node + linkType: hard + "redux-immutable@npm:^4.0.0": version: 4.0.0 resolution: "redux-immutable@npm:4.0.0" @@ -32675,6 +33261,13 @@ __metadata: languageName: node linkType: hard +"url-template@npm:^2.0.8": + version: 2.0.8 + resolution: "url-template@npm:2.0.8" + checksum: 10c0/56a15057eacbcf05d52b0caed8279c8451b3dd9d32856a1fdd91c6dc84dcb1646f12bafc756b7ade62ca5b1564da8efd7baac5add35868bafb43eb024c62805b + languageName: node + linkType: hard + "url@npm:^0.11.0": version: 0.11.4 resolution: "url@npm:0.11.4" @@ -33737,6 +34330,13 @@ __metadata: languageName: node linkType: hard +"yallist@npm:4.0.0, yallist@npm:^4.0.0": + version: 4.0.0 + resolution: "yallist@npm:4.0.0" + checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a + languageName: node + linkType: hard + "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" @@ -33744,13 +34344,6 @@ __metadata: languageName: node linkType: hard -"yallist@npm:^4.0.0": - version: 4.0.0 - resolution: "yallist@npm:4.0.0" - checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a - languageName: node - linkType: hard - "yaml@npm:^1.10.0, yaml@npm:^1.10.2, yaml@npm:^1.7.2": version: 1.10.2 resolution: "yaml@npm:1.10.2"