From 5b46aca250a2c4e4584e8cf1983d67fb4f858be0 Mon Sep 17 00:00:00 2001 From: Anthony Weems Date: Wed, 16 Mar 2022 17:09:23 -0400 Subject: [PATCH] Add support for token-based authentication Co-authored-by: Dallas Kaman --- src/getStepStartStates.ts | 10 +-- src/google-cloud/client.ts | 78 +++++++++++++++++----- src/index.test.ts | 2 +- src/index.ts | 4 ++ src/steps/enablement.ts | 19 +++--- src/steps/storage/index.ts | 2 +- src/types.ts | 5 +- src/utils/integrationConfig.ts | 6 +- src/utils/maybeDefaultProjectIdOnEntity.ts | 2 +- 9 files changed, 89 insertions(+), 39 deletions(-) diff --git a/src/getStepStartStates.ts b/src/getStepStartStates.ts index b1141ab9..1386e350 100644 --- a/src/getStepStartStates.ts +++ b/src/getStepStartStates.ts @@ -153,9 +153,9 @@ function validateInvocationConfig( const { instance } = context; const { config } = instance; - if (!config.serviceAccountKeyFile) { + if (!config.serviceAccountKeyFile && !config.accessToken) { throw new IntegrationValidationError( - 'Missing a required integration config value {serviceAccountKeyFile}', + 'Missing a required integration config value {serviceAccountKeyFile} or {accessToken}', ); } } @@ -200,8 +200,6 @@ export default async function getStepStartStates( projectId: config.projectId, configureOrganizationProjects: config.configureOrganizationProjects, organizationId: config.organizationId, - serviceAccountKeyEmail: config.serviceAccountKeyConfig.client_email, - serviceAccountKeyProjectId: config.serviceAccountKeyConfig.project_id, folderId: config.folderId, }, 'Starting integration with config', @@ -209,9 +207,7 @@ export default async function getStepStartStates( logger.publishEvent({ name: 'integration_config', - description: `Starting Google Cloud integration with service account (email=${ - config.serviceAccountKeyConfig.client_email - }, configureOrganizationProjects=${!!config.configureOrganizationProjects})`, + description: `Starting Google Cloud integration (project=${config.projectId}, configureOrganizationProjects=${!!config.configureOrganizationProjects})`, }); const masterOrgInstance = isMasterOrganizationInstance(config); diff --git a/src/google-cloud/client.ts b/src/google-cloud/client.ts index 4abb0676..af64c830 100644 --- a/src/google-cloud/client.ts +++ b/src/google-cloud/client.ts @@ -1,6 +1,6 @@ import { IntegrationConfig } from '../types'; import { google } from 'googleapis'; -import { CredentialBody, BaseExternalAccountClient } from 'google-auth-library'; +import { CredentialBody, BaseExternalAccountClient, OAuth2Client, UserRefreshClient } from 'google-auth-library'; import { GaxiosResponse } from 'gaxios'; import { IntegrationProviderAuthorizationError, @@ -54,11 +54,14 @@ export async function iterateApi( } export class Client { - readonly projectId: string; + sourceProjectId: string; + readonly projectId?: string; readonly organizationId?: string; readonly folderId?: string; - private credentials: CredentialBody; + private serviceAccountKeyConfig?: CredentialBody; + private accessTokenConfig?: string; + private auth: BaseExternalAccountClient; private readonly onRetry?: (err: any) => void; @@ -66,25 +69,68 @@ export class Client { this.projectId = projectId || config.projectId || - config.serviceAccountKeyConfig.project_id; + config.serviceAccountKeyConfig?.project_id; this.organizationId = organizationId || config.organizationId; - this.credentials = { - client_email: config.serviceAccountKeyConfig.client_email, - private_key: config.serviceAccountKeyConfig.private_key, - }; + if (config.serviceAccountKeyConfig) { + this.serviceAccountKeyConfig = { + client_email: config.serviceAccountKeyConfig.client_email, + private_key: config.serviceAccountKeyConfig.private_key, + }; + this.sourceProjectId = config.serviceAccountKeyConfig.project_id; + } else if (config.accessToken) { + this.accessTokenConfig = config.accessToken; + } else { + throw new Error(`Invalid credentials`); + } this.folderId = config.folderId; this.onRetry = onRetry; } private async getClient(): Promise { - const auth = new google.auth.GoogleAuth({ - credentials: this.credentials, - scopes: ['https://www.googleapis.com/auth/cloud-platform'], - }); - - const client = (await auth.getClient()) as BaseExternalAccountClient; - await client.getAccessToken(); - return client; + if (this.serviceAccountKeyConfig) { + const auth = new google.auth.GoogleAuth({ + credentials: this.serviceAccountKeyConfig, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + + const client = (await auth.getClient()) as BaseExternalAccountClient; + await client.getAccessToken(); + return client; + } else if (this.accessTokenConfig) { + const oAuth2Client = new OAuth2Client(); + const token = this.accessTokenConfig; + oAuth2Client.credentials = { + access_token: token, + } + + /* + * Users of the google-cloud Client sometimes require knowledge of the + * project ID that the service account belongs to. For example, the service + * usage step uses this to avoid scanning excess services when they are not + * enabled in the main project. With a service account key, the project ID + * is embedded in the JSON itself. With an access token, we must interrogate + * Google Cloud APIs to determine the project ID. Specifically we call: + * - oauth2.googleapis.com/tokeninfo + * - iam.googleapis.com/v1/projects/-/serviceAccounts/{serviceAccount} + * - cloudresourcemanager.googleapis.com/v1/projects/{projectId} + */ + const tokenInfo = await oAuth2Client.getTokenInfo(token); + const iam = google.iam('v1'); + const crm = google.cloudresourcemanager('v1'); + + const sa = await iam.projects.serviceAccounts.get({ access_token: token, name: `projects/-/serviceAccounts/${tokenInfo.azp}` }); + const project = await crm.projects.get({ access_token: token, projectId: sa.data.projectId || undefined }); + if (!project.data.projectNumber) { + throw new Error( + `Could not find project number for service account ${tokenInfo.email}`, + ); + } + this.sourceProjectId = project.data.projectNumber; + + return oAuth2Client as UserRefreshClient; + } else { + throw new Error(`Invalid credentials`); + } } async getAuthenticatedServiceClient(): Promise { diff --git a/src/index.test.ts b/src/index.test.ts index e6e45c3f..8fa7ffd9 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -189,7 +189,7 @@ async function validateInvocationInvalidConfigTest({ expect(err instanceof IntegrationValidationError).toBe(true); expect(err.message).toEqual( expectedErrorMessage || - 'Missing a required integration config value {serviceAccountKeyFile}', + 'Missing a required integration config value {serviceAccountKeyFile} or {accessToken}', ); failed = true; } diff --git a/src/index.ts b/src/index.ts index fcde003a..2a436d15 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,10 @@ export const invocationConfig: IntegrationInvocationConfig = type: 'string', mask: true, }, + accessToken: { + type: 'string', + mask: true, + }, projectId: { type: 'string', }, diff --git a/src/steps/enablement.ts b/src/steps/enablement.ts index 63ac19a5..fe73a03c 100644 --- a/src/steps/enablement.ts +++ b/src/steps/enablement.ts @@ -1,7 +1,7 @@ import { StepStartState } from '@jupiterone/integration-sdk-core'; import { ServiceUsageName } from '../google-cloud/types'; import { IntegrationConfig } from '../types'; -import { collectEnabledServicesForProject } from './service-usage/client'; +import { collectEnabledServicesForProject, ServiceUsageClient } from './service-usage/client'; export interface EnabledServiceData { // Enabled APIs in the Google Cloud Project that the Service Account used to authenticate with resides. @@ -32,12 +32,18 @@ export interface EnabledServiceData { export async function getEnabledServiceNames( config: IntegrationConfig, ): Promise { + + // look up main project ID from authenticated client + const client = new ServiceUsageClient({ config }); + await client.getAuthenticatedServiceClient(); + const mainProjectId = client.sourceProjectId; + const targetProjectId = config.projectId; - const mainProjectId = config.serviceAccountKeyConfig.project_id; const enabledServiceData: EnabledServiceData = {}; - const mainProjectEnabledServices = await getMainProjectEnabledServices( + const mainProjectEnabledServices = await collectEnabledServicesForProject( config, + mainProjectId, ); enabledServiceData.mainProjectEnabledServices = mainProjectEnabledServices; @@ -71,13 +77,6 @@ export async function getEnabledServiceNames( return enabledServiceData; } -export async function getMainProjectEnabledServices(config: IntegrationConfig) { - return await collectEnabledServicesForProject( - config, - config.serviceAccountKeyConfig.project_id, - ); -} - export function createStepStartState( enabledServiceNames: string[], primaryServiceName: ServiceUsageName, diff --git a/src/steps/storage/index.ts b/src/steps/storage/index.ts index 39cc71b4..fe531569 100644 --- a/src/steps/storage/index.ts +++ b/src/steps/storage/index.ts @@ -58,7 +58,7 @@ export async function fetchStorageBuckets( const bucketEntity = createCloudStorageBucketEntity({ data: bucket, - projectId: config.serviceAccountKeyConfig.project_id, + projectId: bucket.projectNumber || client.sourceProjectId, bucketPolicy, publicAccessPreventionPolicy, }); diff --git a/src/types.ts b/src/types.ts index 0cf06590..47e0e395 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,8 @@ export type IntegrationStepContext = IntegrationStepExecutionContext; export interface SerializedIntegrationConfig extends IntegrationInstanceConfig { - serviceAccountKeyFile: string; + serviceAccountKeyFile?: string; + accessToken?: string; organizationId?: string; /** * The project ID that this integration should target for ingestion. This @@ -26,7 +27,7 @@ export interface SerializedIntegrationConfig extends IntegrationInstanceConfig { } export interface IntegrationConfig extends SerializedIntegrationConfig { - serviceAccountKeyConfig: ParsedServiceAccountKeyFile; + serviceAccountKeyConfig?: ParsedServiceAccountKeyFile; // HACK - used to prevent binding step ingestion for large accounts. Think twice before using. markBindingStepsAsPartial?: boolean; } diff --git a/src/utils/integrationConfig.ts b/src/utils/integrationConfig.ts index 20666329..4808f147 100644 --- a/src/utils/integrationConfig.ts +++ b/src/utils/integrationConfig.ts @@ -2,7 +2,7 @@ import { IntegrationConfig, SerializedIntegrationConfig } from '../types'; import { parseServiceAccountKeyFile } from './parseServiceAccountKeyFile'; /** - * The incoming Google Cloud config includes a `serviceAccountKeyFile` property + * The incoming Google Cloud config may include a `serviceAccountKeyFile` property * that we need to deserialize. We will override the value of the * `IntegrationExecutionContext` `config` with the return value of this function, * so that the deserialized config can be used throughout all of the steps. @@ -10,6 +10,10 @@ import { parseServiceAccountKeyFile } from './parseServiceAccountKeyFile'; export function deserializeIntegrationConfig( serializedIntegrationConfig: SerializedIntegrationConfig, ): IntegrationConfig { + if (!serializedIntegrationConfig.serviceAccountKeyFile) { + return serializedIntegrationConfig; + } + const parsedServiceAccountKeyFile = parseServiceAccountKeyFile( serializedIntegrationConfig.serviceAccountKeyFile, ); diff --git a/src/utils/maybeDefaultProjectIdOnEntity.ts b/src/utils/maybeDefaultProjectIdOnEntity.ts index a27be5ae..c2998346 100644 --- a/src/utils/maybeDefaultProjectIdOnEntity.ts +++ b/src/utils/maybeDefaultProjectIdOnEntity.ts @@ -25,6 +25,6 @@ export function maybeDefaultProjectIdOnEntity(context, entity: Entity): Entity { projectId: entity.projectId ?? context.instance.config.projectId ?? - context.instance.config.serviceAccountKeyConfig.project_id, + context.instance.config.serviceAccountKeyConfig?.project_id, }; }