simenandre / pulumi-gcp-scaffold

Apache License 2.0
1 stars 0 forks source link

Create a service account with key and project roles #4

Open simenandre opened 3 years ago

simenandre commented 3 years ago

Developers are most likely going to need to create a key for any service account created, which should happen under the hood. IMHO, You should be able to add project roles as well easily. It's closer to the experience developers are used to from GCP Console (create a new service account form).

Imagine this API:


const serviceAccount = new scaffold.ServiceAccount('hello', {
    accountId: 'hello', // eq hello@....
    roles: ['roles/clouddebugger.agent', 'roles/documentai.apiUser'],
});

export const serviceAccountKey = serviceAccount.key.privateKey;

Let's look at the current alternative:

const serviceAccount = new gcp.serviceaccount.Account('hello', {
      displayName: `hello`,
      description: `hello`,
      accountId: 'hello',
});

const serviceAccountKey = new gcp.serviceaccount.Key('hello', {
    serviceAccountId: serviceAccount.id,
});

const cloudDebuggerIamMember = new gcp.projects.IAMMember('hello', {
  role: 'roles/clouddebugger.agent',
  member: pulumi.interpolate`serviceAccount:${serviceAccount.email}`,
});

const documentAiApiUserIamMember = new gcp.projects.IAMMember('hello', {
  role: 'roles/documentai.apiUser',
  member: pulumi.interpolate`serviceAccount:${serviceAccount.email}`,
});

IMHO, the goal here is to have an API that is easily remembered. If you go some weeks between adding service accounts, you shouldn't have to go back to Pulumi docs or whatever to understand what to do. Nor should you have to copy something because the boilerplate is so much, at least for the basic stuff.

simenandre commented 3 years ago

We should probably escape the accountId input. Google has some special requirements regarding the naming, and we could solve that by appending random stuff or removing (and warn the user).

Something like this maybe?

function escapeName(
  name: pulumi.Input<string>,
  minLen = 8,
  maxLen = 28,
): pulumi.Output<string> {
  return pulumi.output(name).apply(sName => {
    sName = sName.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase();
    if (sName.length < minLen) {
      const missingLen = maxLen - sName.length - 1;
      invariant(missingLen > 0, 'Expect length to be higher than zero');
      const rand = new random.RandomString(sName, {
        length: missingLen,
        special: false,
        upper: false,
      });
      return pulumi.interpolate`${sName}-${rand.result}`;
    }
    return pulumi.output(sName.substring(0, maxLen));
  });
}
stack72 commented 3 years ago

I love the idea of adding the service key and a list of roles - I think that makes much more sense

I am not 100% sure what we need to do about accountId but I trust your judgement here - what do you want the format of the accountIds to be?

simenandre commented 3 years ago

I made a simple implementation of this, to see what makes the most sense in a real world situation. Anyone playing along from home (and use Typescript) can use this:

import * as gcp from '@pulumi/gcp';
import * as pulumi from '@pulumi/pulumi';
import { escapeName } from '../utils'; // From above

export interface ServiceAccountSpec {
  accountId: pulumi.Input<string>;
  roles?: pulumi.Input<string[]>;
}

export class ServiceAccount extends pulumi.ComponentResource {
  account: pulumi.Output<gcp.serviceaccount.Account>;
  key: pulumi.Output<gcp.serviceaccount.Key>;
  roles: pulumi.Output<gcp.projects.IAMMember[]>;

  constructor(
    name: string,
    args: ServiceAccountSpec,
    opts?: pulumi.ComponentResourceOptions,
  ) {
    super('indiv:service-account', name, args, opts);

    const { accountId, roles = [] } = args;

    this.account = pulumi.output(
      serviceAccount ||
        new gcp.serviceaccount.Account(
          `${name}_service_account`,
          {
            displayName: `Service account for ${name}`,
            description: `Service account for ${name}`,
            accountId: escapeName(accountId),
          },
          { parent: this },
        ),
    );

    this.key = pulumi.output(
      new gcp.serviceaccount.Key(
        `${name}_service_key`,
        {
          serviceAccountId: this.account.id,
        },
        { parent: this },
      ),
    );

    this.roles = pulumi.output(roles).apply(r =>
      r.map(
        role =>
          new gcp.projects.IAMMember(
            `${name}-${role}`,
            {
              role,
              member: pulumi.interpolate`serviceAccount:${this.account.email}`,
            },
            { parent: this },
          ),
      ),
    );
  }
}

Feedback is appreciated! 🙏