Skip to content
Open
68 changes: 68 additions & 0 deletions src/v2/components/password/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
import * as random from '@pulumi/random';
import { commonTags } from '../../../constants';

export namespace Password {
export type Args = {
value?: pulumi.Input<string>;
};
}

export class Password extends pulumi.ComponentResource {
name: string;
value: pulumi.Output<string>;
secret: aws.secretsmanager.Secret;

constructor(
name: string,
args: Password.Args = {},
opts: pulumi.ComponentResourceOptions = {},
) {
super('studion:Password', name, {}, opts);

this.name = name;
if (args.value) {
this.value = pulumi.secret(args.value);
} else {
const password = new random.RandomPassword(
`${this.name}-random-password`,
{
length: 16,
overrideSpecial: '_$',
special: true,
},
{ parent: this },
);
this.value = password.result;
}

this.secret = this.createPasswordSecret(this.value);
this.registerOutputs();
}

private createPasswordSecret(password: pulumi.Input<string>) {
const project = pulumi.getProject();
const stack = pulumi.getStack();

const passwordSecret = new aws.secretsmanager.Secret(
`${this.name}-password-secret`,
{
namePrefix: `${stack}/${project}/${this.name}-`,
tags: commonTags,
},
{ parent: this },
);

const passwordSecretValue = new aws.secretsmanager.SecretVersion(
`${this.name}-password-secret-value`,
{
secretId: passwordSecret.id,
secretString: password,
},
{ parent: this, dependsOn: [passwordSecret] },
);

return passwordSecret;
}
}
1 change: 1 addition & 0 deletions src/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { WebServerLoadBalancer } from './components/web-server/load-balancer';
export { ElastiCacheRedis } from './components/redis/elasticache-redis';
export { UpstashRedis } from './components/redis/upstash-redis';
export { Vpc } from './components/vpc';
export { Password } from './components/password';

import { OtelCollectorBuilder } from './otel/builder';
import { OtelCollector } from './otel';
Expand Down
121 changes: 121 additions & 0 deletions tests/password/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import * as assert from 'node:assert';
import { InlineProgramArgs } from '@pulumi/pulumi/automation';
import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager';
import {
DescribeSecretCommand,
GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';
import { PasswordTestContext } from './test-context';
import { after, before, describe, it } from 'node:test';
import * as automation from '../automation';

const programArgs: InlineProgramArgs = {
stackName: 'dev',
projectName: 'icb-test-password',
program: () => import('./infrastructure'),
};

describe('Password component deployment', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we'll be able to test this, but it is worth to investigate - is the password value masked in the output of the Pulumi program?

Note: it should be due to additionalSecretOutputs option sent to the super, but having that test would be beneficial to capture potential leaks.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've done some investigation and seems like additionalSecretOutputs isn't even working for custom components as it states in the docs.
I had to wrap the value in pulumi.secret() which behaves the same as output except the returned output is marked as containing sensitive data.
I also implemented a test to verify that the password is treated as a secret. To make that test work, I had to export the password output directly from the infrastructure index file. The reason is that the automation api unwraps values, and for objects it only keeps the secret flag at the top level. Because of that, there’s no reliable way to check whether nested properties are secrets once they’re unwrapped.

const region = process.env.AWS_REGION;
if (!region) {
throw new Error('AWS_REGION environment variable is required');
}

const ctx: PasswordTestContext = {
outputs: {},
config: {
autoGeneratedPasswordName: 'password-test-auto',
},
clients: {
secretsManager: new SecretsManagerClient({ region }),
},
};

before(async () => {
ctx.outputs = await automation.deploy(programArgs);
});

after(() => automation.destroy(programArgs));

it('should create a password component with the correct configuration', async () => {
const password = ctx.outputs.autoGeneratedPassword.value;

assert.ok(
password.secret,
'Password component should have secret property',
);
assert.ok(password.value, 'Password component should have value property');
assert.strictEqual(
password.name,
ctx.config.autoGeneratedPasswordName,
'Password name should match input',
);
});

it('should create a secret with auto generated password', async () => {
const password = ctx.outputs.autoGeneratedPassword.value;

const secretResult = await ctx.clients.secretsManager.send(
new DescribeSecretCommand({
SecretId: password.secret.arn,
}),
);

assert.ok(secretResult.ARN, 'Secret should exist');
assert.ok(secretResult.Name, 'Secret should have a name');
assert.ok(secretResult.CreatedDate, 'Secret should have creation date');

const expectedPrefix = `${programArgs.stackName}/${programArgs.projectName}/${ctx.config.autoGeneratedPasswordName}-`;
assert.ok(
secretResult.Name?.startsWith(expectedPrefix),
`Secret name should start with ${expectedPrefix}`,
);
});

it('should generate a random password with correct format', async () => {
const password = ctx.outputs.autoGeneratedPassword.value;

const secretValue = await ctx.clients.secretsManager.send(
new GetSecretValueCommand({
SecretId: password.secret.arn,
}),
);

const passwordValue = secretValue.SecretString;
assert.ok(passwordValue, 'Password value should exist');
assert.strictEqual(
passwordValue.length,
16,
'Password should be 16 characters long',
);
assert.ok(secretValue.VersionId, 'Secret should have a version ID');
});

it('should create a secret with custom password value', async () => {
const password = ctx.outputs.customPassword.value;

const secretValue = await ctx.clients.secretsManager.send(
new GetSecretValueCommand({
SecretId: password.secret.arn,
}),
);

const passwordValue = secretValue.SecretString;
assert.strictEqual(
passwordValue,
'customPass!',
'Password should match custom value',
);
});

it('should have password value as a secret', async () => {
const autoGeneratePasswordValue = ctx.outputs.autoGeneratePasswordValue;
assert.ok(
autoGeneratePasswordValue.secret,
'Auto-generated password should be a secret',
);

const customPasswordValue = ctx.outputs.customPasswordValue;
assert.ok(customPasswordValue.secret, 'Custom password should be a secret');
});
});
18 changes: 18 additions & 0 deletions tests/password/infrastructure/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { next as studion } from '@studion/infra-code-blocks';

const appName = 'password-test';

const autoGeneratedPassword = new studion.Password(`${appName}-auto`);
const autoGeneratePasswordValue = autoGeneratedPassword.value;

const customPassword = new studion.Password(`${appName}-custom`, {
value: 'customPass!',
});
const customPasswordValue = customPassword.value;

export {
autoGeneratedPassword,
customPassword,
autoGeneratePasswordValue,
customPasswordValue,
};
25 changes: 25 additions & 0 deletions tests/password/test-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { OutputMap } from '@pulumi/pulumi/automation';
import { SecretsManagerClient } from '@aws-sdk/client-secrets-manager';

interface PasswordTestConfig {
autoGeneratedPasswordName: string;
}

interface ConfigContext {
config: PasswordTestConfig;
}

interface PulumiProgramContext {
outputs: OutputMap;
}

interface AwsContext {
clients: {
secretsManager: SecretsManagerClient;
};
}

export interface PasswordTestContext
extends ConfigContext,
PulumiProgramContext,
AwsContext {}