reshuffle-aws-connectors
Version: 
A set of Reshuffle connectors for AWS services
200 lines (173 loc) • 5.3 kB
text/typescript
import AWS from 'aws-sdk'
import crypto from 'crypto'
import objhash from 'object-hash'
import { CoreConnector, Options, Reshuffle } from './CoreConnector'
AWS.config.signatureVersion = 'v4'
export { AWS }
export function validateAccesKeyId(accessKeyId: string): string {
  if (!/^AK[A-Z0-9]{18}$/.test(accessKeyId)) {
    throw new Error(`Invalid accessKeyId: ${accessKeyId}`)
  }
  return accessKeyId
}
export function validateBucket(bucket: string): string {
  if (
    !bucket ||
    !/(?=^.{3,63}$)(?!^(\d+\.)+\d+$)(^(([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9\-]*[a-z0-9])$)/.test(
      bucket,
    )
  ) {
    throw new Error(`Invalid bucket: ${bucket}`)
  }
  return bucket
}
export function validateRegion(region: string): string {
  if (
    !/^(af|ap|ca|cn|eu|me|sa|us|us-gov)-(central|east|north|northeast|northwest|south|southeast|southwest|west)-\d$/.test(
      region,
    )
  ) {
    throw new Error(`Invalid region: ${region}`)
  }
  return region
}
export function validateS3URL(url: string): string {
  const match: any = url.match(/^s3:\/\/([^\/]+)\/(([^\/]+\/)*)([^\/]+)$/)
  try {
    validateBucket(match[1])
  } catch {
    throw new Error(`Invalid bucket in S3 URL: ${url}`)
  }
  return url
}
export function validateSecretAccessKey(secretAccessKey: string): string {
  if (!/^[A-Za-z0-9\/\+=]{40}$/.test(secretAccessKey)) {
    throw new Error(`Invalid secretAccessKey: ${secretAccessKey}`)
  }
  return secretAccessKey
}
export function validateURL(url: string): string {
  if (
    !/^https?:\/\/([^:]+(:[^@]+)?@)?[0-9a-zA-Z_-]+(\.[0-9a-zA-Z_-]+)*(\/[\.0-9a-zA-Z_-]+)*\/?$/.test(
      url,
    )
  ) {
    throw new Error(`Invalid URL: ${url}`)
  }
  return url
}
class AWSAccount {
  private clients: Record<string, any> = {}
  constructor(private options: Options) {
    validateAccesKeyId(options.accessKeyId)
    validateSecretAccessKey(options.secretAccessKey)
    if (options.region) {
      validateRegion(options.region)
    }
  }
  public getClient(service: string, options: Options = {}): any {
    const opts = { ...this.options, ...options }
    const hash = objhash({ service, opts })
    if (!this.clients[hash]) {
      const constructor: any = (AWS as any)[service]
      this.clients[hash] = new constructor(opts)
    }
    return this.clients[hash]
  }
  public getCredentials() {
    if (!this.options) {
      throw new Error('Credentials must be specified in connector options')
    }
    return {
      accessKeyId: this.options.accessKeyId,
      secretAccessKey: this.options.secretAccessKey,
    }
  }
}
export interface AWSPolicyStatement {
  effect: string
  action: string[]
  resource: string
}
class AWSIdentity {
  constructor(private account: AWSAccount) {}
  public createPolicy(statements: AWSPolicyStatement | AWSPolicyStatement[]) {
    const sts = Array.isArray(statements) ? statements : [statements]
    return {
      Version: '2012-10-17',
      Statement: sts.map((st) => ({
        Effect: st.effect,
        Action: st.action,
        Resource: st.resource,
      })),
    }
  }
  public createSimplePolicy(resource: string, action: string[], effect = 'Allow') {
    return this.createPolicy({ effect, resource, action })
  }
  public async getOrCreateServiceRole(
    roleName: string,
    service: string,
    policies?: string | Record<string, any> | Array<string | Record<string, any>>,
  ) {
    const iam = this.account.getClient('IAM')
    try {
      const res = await iam.getRole({ RoleName: roleName }).promise()
      return res.Role
    } catch (e) {
      if (e.code !== 'NoSuchEntity') {
        throw e
      }
      console.log(`Creating IAM role for service ${service}: ${roleName}`)
      const res = await iam
        .createRole({
          RoleName: roleName,
          AssumeRolePolicyDocument: JSON.stringify({
            Version: '2012-10-17',
            Statement: [
              {
                Effect: 'Allow',
                Principal: {
                  Service: service,
                },
                Action: 'sts:AssumeRole',
              },
            ],
          }),
        })
        .promise()
      const policiesArray =
        policies === undefined ? [] : Array.isArray(policies) ? policies : [policies]
      for (const policy of policiesArray) {
        if (typeof policy === 'string') {
          await iam
            .attachRolePolicy({
              RoleName: roleName,
              PolicyArn: policy,
            })
            .promise()
        } else {
          await iam
            .putRolePolicy({
              PolicyDocument: JSON.stringify(policy),
              PolicyName: `policy_${roleName}_${crypto.randomBytes(4).toString('hex')}`,
              RoleName: roleName,
            })
            .promise()
        }
      }
      // It takes a while for a service role to become assumable
      await new Promise((resolve) => setTimeout(resolve, 10000))
      return res.Role
    }
  }
}
export class BaseAWSConnector extends CoreConnector {
  protected account: AWSAccount
  protected identity: AWSIdentity
  constructor(app: Reshuffle, options: Options, id?: string) {
    super(app, options, id)
    this.account = new AWSAccount(options)
    this.identity = new AWSIdentity(this.account)
  }
}