Version v0.42.0 of the
@kensio/yulin
npm package includes a
simulated Secrets Manager
for local development and isolated testing, with secret values encrypted through simulated KMS.
Secrets are held in memory and versioned on every update. Every operation is authorized by simulated IAM against the equivalent real IAM action.
Creating a secret and reading it back is similar to how it works with the real AWS SDK:
import {
CreateSecretCommand,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const secretsManager = simAws.secretsManager();
await secretsManager.createSecret(
new CreateSecretCommand({
Name: "db-creds",
SecretString: JSON.stringify({ username: "app", password: "hunter2" }),
}),
);
const read = await secretsManager.getSecretValue(
new GetSecretValueCommand({ SecretId: "db-creds" }),
);
Secrets Manager-specific types come from the @kensio/yulin/secretsmanager subpath.
Every update creates a new version rather than replacing one. AWSCURRENT names the version a plain
read returns, and writing a new current version demotes the previous one to AWSPREVIOUS.
A rotation can then be walked through by hand in a test, with both values read back:
import { PutSecretValueCommand } from "@aws-sdk/client-secrets-manager";
await secretsManager.createSecret(
new CreateSecretCommand({ Name: "api-key", SecretString: "old-key" }),
);
await secretsManager.putSecretValue(
new PutSecretValueCommand({ SecretId: "api-key", SecretString: "new-key" }),
);
const current = await secretsManager.getSecretValue(
new GetSecretValueCommand({ SecretId: "api-key" }),
);
const previous = await secretsManager.getSecretValue(
new GetSecretValueCommand({
SecretId: "api-key",
VersionStage: "AWSPREVIOUS",
}),
);
A ClientRequestToken becomes the version id, as it does on real AWS. Repeating an update with the
same token and the same value is ignored, so a retry is safe. The same token with a different value
is refused, because a version’s value never changes once written.
Real Secrets Manager appends a hyphen and six random characters to the secret name in its ARN, so a
secret named db-creds gets an ARN ending :secret:db-creds-AbCdEf. Sim Secrets Manager does the
same.
An IAM policy naming the bare ARN therefore matches nothing. A policy has to allow for the suffix:
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:eu-west-1:123456789012:secret:db-creds-??????"
}
Sim IAM evaluates that policy, so a test acting as a Role gets the same denial it would get on real AWS when the six wildcard characters are missing.
ListSecrets is the exception. Real Secrets Manager gives it no resource-level permissions, so a
policy allowing it has to use a resource of *. A policy naming individual secret ARNs doesn’t
actually allow any listing, which matches the behaviour in real AWS.
Every version is encrypted through
simulated KMS when it is written, and
decrypted when GetSecretValue reads it. There is no flag for reading a secret without decrypting
it, so the read either returns the plaintext or fails.
A secret that does not specify a KmsKeyId uses the aws/secretsmanager AWS managed key, which
does not ask the caller for a KMS permission. Secrets Manager supplies kms:ViaService, and that
key’s policy allows the account’s principals to use it through Secrets Manager. A Lambda role
granted only secretsmanager:GetSecretValue can therefore read the secret, as it does on real AWS.
Passing KmsKeyId encrypts under a customer-managed key instead, and then the caller’s own
permissions on that key will be involved in permission decisions.
Envelope encryption is supported, with a data key per version, so a write needs
kms:GenerateDataKey and a read needs kms:Decrypt.
A Role that is allowed access to the secret but not the key fails at the read rather than in a deployment:
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { CreateKeyCommand } from "@aws-sdk/client-kms";
import {
CreateSecretCommand,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const accountId = simAws.defaultAccountId;
const key = await simAws
.kms()
.createKey(new CreateKeyCommand({ Description: "Secret key" }));
await simAws.secretsManager().createSecret(
new CreateSecretCommand({
Name: "db-credentials",
SecretString: "hunter2",
KmsKeyId: key.KeyMetadata?.Arn,
}),
);
const role = await simAws.iam().createRole(
new CreateRoleCommand({
RoleName: "SecretReader",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: { AWS: `arn:aws:iam::${accountId}:root` },
Action: "sts:AssumeRole",
},
}),
}),
);
// The secret is allowed, the key is not.
await simAws.iam().putRolePolicy(
new PutRolePolicyCommand({
RoleName: "SecretReader",
PolicyName: "ReadDbCredentials",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Action: "secretsmanager:GetSecretValue",
Resource: "*",
},
}),
}),
);
try {
await simAws
.secretsManager()
.getSecretValue(new GetSecretValueCommand({ SecretId: "db-credentials" }), {
caller: { kind: "arn", arn: role.Role.Arn },
});
} catch (error) {
console.log((error as Error).name); // "AccessDenied"
}
Each version is bound to its own secret ARN and version id as the KMS encryption context, similar to how real Secrets Manager binds them.
A KmsKeyId naming a key that does not exist, is disabled or is pending deletion fails with
EncryptionFailure when a value is written under it. A version whose key has since become unusable
fails with DecryptionFailure when it is read.
Changing KmsKeyId applies to versions written afterwards. Versions already written keep the key
they were made with and stay readable, which is what real AWS does too.
Secrets can also be deployed from a template. Simulated CloudFormation creates one from an
AWS::SecretsManager::Secret resource, and the template either supplies the value with
SecretString or asks for one to be generated with GenerateSecretString:
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "database-stack",
template: {
Resources: {
DbSecret: {
Type: "AWS::SecretsManager::Secret",
Properties: {
Name: "db-credentials",
GenerateSecretString: {
SecretStringTemplate: JSON.stringify({ username: "app" }),
GenerateStringKey: "password",
PasswordLength: 24,
ExcludePunctuation: true,
},
},
},
},
Outputs: {
DbSecretArn: { Value: { Ref: "DbSecret" } },
},
},
});
await stack.waitForDeployComplete();
Ref gives the full secret ARN, including the random suffix, so it works directly as a SecretId.
That is true whether it goes into a Lambda’s environment or into an IAM policy resource.
Generated passwords are random, so a test should read the value back out of the simulation the way a
deployed application does rather than predicting it. An empty GenerateSecretString: {} makes the
whole secret value the generated password, which is what CDK synthesises for a
secretsmanager.Secret with no options.
Function code running in
simulated Lambda is covered too. A
handler requiring @aws-sdk/client-secrets-manager is routed into the same simulation, with the
function’s execution role as the caller. It therefore has to be allowed to fetch the secret by that
role’s policy, as on real AWS.
DeleteSecret does not immediately delete. It schedules deletion after a recovery window of 7 to 30
days, defaulting to 30. During that window the secret can still be described and restored, though it
refuses to be read or written. It also still holds its name.
There are a few limitations worth knowing about.
A KmsKeyId is checked when a version is written under it, rather than when it is set on its own.
An UpdateSecret changing only the key accepts a key that does not exist, and the next update of a
value is what fails.
Rotation is not simulated. RotateSecret, CancelRotateSecret and the rotation Lambda protocol are
absent, and DescribeSecret always reports RotationEnabled as false. Staging labels can still
be moved by hand, as above.
Resource policies are not simulated either, so cross-account access to a secret cannot be granted.
One piece of validation is deliberately stricter than AWS. A secret name ending in a hyphen and six alphanumeric characters is refused, because it cannot be told apart from an ARN’s resource part when a partial ARN is resolved. Real AWS only advises against such names.
That does rule out ordinary-looking ones like app-secret and prod-config, since secret and
config are both six characters. Use app-credentials or prod-settings instead.
A secret deployed from a template is the same secret a handler reads, so both can be covered in one test run without anything leaving the process.