Kensio Software Blog »

@kensio/yulin adds simulated Secrets Manager

Hugh Grigg | Kensio Software |

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.

Related Technologies

AWS   TypeScript   Node.js