Skip to content

Simulated SSM Parameter Store

Yulin simulates AWS Systems Manager Parameter Store for tests and local development. You can create, read, update and delete parameters through the AWS SDK or a CloudFormation template. Parameters are stored in memory, each write creates a version, and simulated IAM authorizes every operation.

Other Systems Manager features, such as Run Command and Session Manager, are not simulated. Import SSM-specific types from @kensio/yulin/ssm.

PutParameter needs a Type when the parameter is new. GetParameter returns the current version.

/**
* Writing a simulated parameter and reading it back.
*/
import { GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const ssm = simAws.ssm();
await ssm.putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-host",
Type: "String",
Value: "db.internal",
}),
);
const read = await ssm.getParameter(
new GetParameterCommand({ Name: "/myapp/prod/db-host" }),
);
console.log(read.Parameter?.Value); // "db.internal"
console.log(read.Parameter?.Version); // 1

A parameter written without a leading slash and one read with it are the same parameter, because both name the same ARN.

A parameter ARN drops the leading slash from the name. /myapp/prod/db-host becomes arn:aws:ssm:eu-west-2:111111111111:parameter/myapp/prod/db-host, with one slash after parameter rather than two. A policy written with the doubled slash matches nothing.

/**
* A simulated IAM policy allowing a Role to read one parameter.
*/
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const accountId = simAws.defaultAccountId;
const regionName = simAws.defaultRegionName;
const role = await simAws.iam().createRole(
new CreateRoleCommand({
RoleName: "ConfigReader",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: { AWS: `arn:aws:iam::${accountId}:root` },
Action: "sts:AssumeRole",
},
}),
}),
);
await simAws.iam().putRolePolicy(
new PutRolePolicyCommand({
RoleName: "ConfigReader",
PolicyName: "ReadDbHost",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Action: "ssm:GetParameter",
// One slash after `parameter`, not two, whatever the name looks like.
Resource: `arn:aws:ssm:${regionName}:${accountId}:parameter/myapp/prod/db-host`,
},
}),
}),
);
await simAws.ssm().putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-host",
Type: "String",
Value: "db.internal",
}),
);
const read = await simAws
.ssm()
.getParameter(new GetParameterCommand({ Name: "/myapp/prod/db-host" }), {
caller: { kind: "arn", arn: role.Role.Arn },
});
console.log(read.Parameter?.Value); // "db.internal"

DescribeParameters is the exception. Real Parameter Store gives that action no resource-level permissions. It authorizes against * here, and a policy naming individual parameter ARNs grants nothing.

GetParametersByPath authorizes against the path rather than against each parameter it returns. Access to a path is access to everything under it. A recursive listing of /myapp returns /myapp/prod/db-host even where a policy explicitly denies that parameter.

Every write makes a new version. PutParameter refuses a name that is already taken unless the request sets Overwrite, and an earlier version stays readable by number.

/**
* Overwriting a simulated parameter and reading an earlier version.
*/
import { GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const ssm = simAws.ssm();
await ssm.putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-host",
Type: "String",
Value: "db.internal",
}),
);
const overwritten = await ssm.putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-host",
Value: "db2.internal",
Overwrite: true,
}),
);
console.log(overwritten.Version); // 2
const first = await ssm.getParameter(
new GetParameterCommand({ Name: "/myapp/prod/db-host:1" }),
);
console.log(first.Parameter?.Value); // "db.internal"
console.log(first.Parameter?.Selector); // ":1"

A parameter’s type cannot change. Overwriting a String parameter as a StringList fails with HierarchyTypeMismatchException, as it does on real AWS. Delete the parameter and create a new one instead.

GetParametersByPath reads a whole level of the hierarchy. Without Recursive it returns only the level immediately below the path.

/**
* Reading a hierarchy of simulated parameters as application configuration.
*/
import {
GetParametersByPathCommand,
PutParameterCommand,
} from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const ssm = simAws.ssm();
await ssm.putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-host",
Type: "String",
Value: "db.internal",
}),
);
await ssm.putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-port",
Type: "String",
Value: "5432",
}),
);
await ssm.putParameter(
new PutParameterCommand({
Name: "/myapp/test/db-host",
Type: "String",
Value: "db.test.internal",
}),
);
const listed = await ssm.getParametersByPath(
new GetParametersByPathCommand({ Path: "/myapp/prod" }),
);
console.log(listed.Parameters?.map((parameter) => parameter.Name));
// [ "/myapp/prod/db-host", "/myapp/prod/db-port" ]

A page holds ten parameters, as it does on real AWS. Follow NextToken to read the rest.

GetParameters takes up to ten names. A name that resolves to nothing comes back in InvalidParameters, and the request still succeeds. That is what makes a typo easy to miss.

/**
* Reading several simulated parameters, including one name with a typo.
*/
import { GetParametersCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const ssm = simAws.ssm();
await ssm.putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-host",
Type: "String",
Value: "db.internal",
}),
);
const read = await ssm.getParameters(
new GetParametersCommand({
Names: ["/myapp/prod/db-host", "/myapp/prod/db-hostt"],
}),
);
console.log(read.Parameters?.map((parameter) => parameter.Name));
// [ "/myapp/prod/db-host" ]
console.log(read.InvalidParameters); // [ "/myapp/prod/db-hostt" ]

GetParameters still authorizes each name. One name the caller may not read fails the whole request.

A StringList value comes back as one comma-separated string, on read as well as on write, never as an array.

/**
* Reading a simulated StringList parameter.
*/
import { GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const ssm = simAws.ssm();
await ssm.putParameter(
new PutParameterCommand({
Name: "/myapp/prod/allowed-origins",
Type: "StringList",
Value: "https://one.example,https://two.example",
}),
);
const read = await ssm.getParameter(
new GetParameterCommand({ Name: "/myapp/prod/allowed-origins" }),
);
const origins = read.Parameter?.Value?.split(",") ?? [];
console.log(origins.length); // 2

Parameter names use the same validation rules as AWS. A name:

  • may contain letters, digits, _, ., - and /
  • must start with / if it contains a hierarchy at all
  • may not have more than fifteen hierarchy levels
  • may not start with aws or ssm in any case, which Parameter Store reserves
  • may not contain spaces between characters, though surrounding spaces are stripped
  • may not make an ARN longer than 1011 characters, counting the ARN prefix for the account and region

A String or StringList value holds at most 4 KB, the standard tier limit. Larger configuration documents must be split across parameters or stored elsewhere.

Simulated CloudFormation creates a parameter from an AWS::SSM::Parameter resource, in the stack’s account and region. The parameter is written through PutParameter. A template-created parameter is the same thing an SDK caller would get, with the same name validation, the same ARN, and version 1.

Ref on the resource gives the parameter name, as it does on real AWS, and it can be handed straight to GetParameter. Fn::GetAtt … Type and Fn::GetAtt … Value give those properties.

/**
* Deploying a parameter from a CloudFormation template and reading it back.
*/
import { GetParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "config-stack",
template: {
Resources: {
DbHost: {
Type: "AWS::SSM::Parameter",
Properties: {
Name: "/myapp/prod/db-host",
Type: "String",
Value: "db.internal",
Description: "Where the application database lives",
},
},
},
Outputs: {
DbHostParameter: {
Value: { Ref: "DbHost" },
},
},
},
});
await stack.waitForDeployComplete();
// Ref resolves to the parameter name, so it works as a GetParameter Name.
const parameterName = stack.output("DbHostParameter");
const read = await simAws
.ssm()
.getParameter(new GetParameterCommand({ Name: parameterName }));
console.log(read.Parameter?.Value); // "db.internal"
console.log(read.Parameter?.Version); // 1

A parameter with no Name is named from the stack name, the logical ID and a tail derived from both. A FeatureFlags in config-stack becomes config-stack-FeatureFlags- and twelve more characters, where real CloudFormation ends the name in twelve random ones. The name carries no leading slash, putting the parameter at the top of the hierarchy where real CloudFormation puts one it names itself. Parameter Store counts the ARN prefix towards the 1011 characters a name may use, and the CloudFormation docs cover how the stack name and the logical ID share what is left.

An IAM policy granting access to a template-created parameter needs the ARN rather than the Ref. Build it with Fn::Sub, remembering that the ARN drops the name’s leading slash:

Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/myapp/prod/db-host"

CDK does this for you. ssm.StringParameter with grantRead(fn) synthesises a template that deploys here without hand-editing.

Reading a parameter with a dynamic reference

Section titled “Reading a parameter with a dynamic reference”

A template reads a parameter that already exists through a {{resolve:ssm:...}} dynamic reference. The reference is replaced with the parameter’s value as the resource holding it is created.

{{resolve:ssm:name}} reads the current version, and {{resolve:ssm:name:3}} reads version 3. A reference can sit inside a longer string, where only the reference itself is replaced.

/**
* Reading an existing parameter from a template with a dynamic reference.
*/
import { GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
await simAws.ssm().putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-host",
Type: "String",
Value: "db.internal",
}),
);
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "app-stack",
template: {
Resources: {
DbUrl: {
Type: "AWS::SSM::Parameter",
Properties: {
Name: "/myapp/prod/db-url",
Type: "String",
Value: "postgres://{{resolve:ssm:/myapp/prod/db-host}}:5432/app",
},
},
},
},
});
await stack.waitForDeployComplete();
const read = await simAws
.ssm()
.getParameter(new GetParameterCommand({ Name: "/myapp/prod/db-url" }));
console.log(read.Parameter?.Value); // "postgres://db.internal:5432/app"

CDK emits one of these from ssm.StringParameter.valueForStringParameter when a version is given, and from fromStringParameterAttributes with forceDynamicReference.

A reference inside Fn::Sub is read after the variables around it are substituted, so !Sub "{{resolve:ssm:/myapp/${Environment}/db-host}}" looks up the name the substitution produced.

A StringList parameter resolves to the comma-separated string Parameter Store holds, which Fn::Split then splits.

Real CloudFormation makes no dependency out of a dynamic reference, and neither does this. A parameter another resource of the same stack creates is only there in time when the template says DependsOn.

The parameter is read with GetParameter, as the caller deploying the stack. A policy denying that read fails the resource holding the reference, the way it fails a real deployment. A stack deployed without a caller reads as the account root.

A SecureString parameter is read through {{resolve:ssm-secure:...}}, which the next section covers. {{resolve:secretsmanager:...}} reads a secret, and simulated Secrets Manager covers that one.

When a reference names a parameter that does not exist, simulated CloudFormation substitutes dummy-value-for-<name> and continues the deployment. This lets a test deploy the rest of a template without setting up unrelated configuration.

The substitution is recorded on stack.ignoredProperties, naming the property that held the reference and why the value is a stand-in. A version the parameter never had, a SecureString, and a body that is not a name and an optional integer version are all recorded the same way.

Reading a SecureString with an ssm-secure reference

Section titled “Reading a SecureString with an ssm-secure reference”

{{resolve:ssm-secure:name}} reads a SecureString parameter and resolves to its decrypted value. {{resolve:ssm-secure:name:3}} reads version 3. The string scanning is the one plain ssm references use, so a reference can sit inside a longer value and inside Fn::Sub.

Decryption goes to simulated KMS under the key the parameter was written with, as the caller deploying the stack. A caller lacking kms:Decrypt on a customer managed key fails the resource with the AccessDenied a decrypting GetParameter raises (see A customer managed key needs its own permission). A parameter under the aws/ssm managed key needs no KMS permission of the caller.

CDK writes one of these from SecretValue.ssmSecure, from ssm.StringParameter.fromSecureStringParameterAttributes and from the deprecated valueForSecureStringParameter.

Real CloudFormation reads an ssm-secure reference in eleven resource properties and refuses it in every other one. Simulated CloudFormation holds a template to the same list.

Resource Property
AWS::DirectoryService::MicrosoftAD Password
AWS::DirectoryService::SimpleAD Password
AWS::ElastiCache::ReplicationGroup AuthToken
AWS::IAM::User LoginProfile.Password
AWS::KinesisFirehose::DeliveryStream RedshiftDestinationConfiguration.Password
AWS::OpsWorks::App Source.Password
AWS::OpsWorks::Stack CustomCookbooksSource.Password
AWS::OpsWorks::Stack RdsDbInstances.DbPassword
AWS::RDS::DBCluster MasterUserPassword
AWS::RDS::DBInstance MasterUserPassword
AWS::Redshift::Cluster MasterUserPassword

A reference anywhere else fails the resource, naming the property that held it. A template breaking this rule is broken on real CloudFormation too.

AWS::IAM::User LoginProfile.Password is the pair a simulated resource holds today, and it is where CDK writes SecretValue.ssmSecure. The other ten name resource types this simulation has yet to reach.

/**
* Reading a SecureString parameter into a resource property from a template.
*/
import { PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
await simAws.ssm().putParameter(
new PutParameterCommand({
Name: "/myapp/prod/console-password",
Type: "SecureString",
Value: "hunter2",
}),
);
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "console-stack",
template: {
Resources: {
ConsoleUser: {
Type: "AWS::IAM::User",
Properties: {
UserName: "ConsoleUser",
LoginProfile: {
Password: "{{resolve:ssm-secure:/myapp/prod/console-password}}",
},
},
},
},
},
});
await stack.waitForDeployComplete();
const user = simAws
.iam()
.users.values()
.find((each) => each.userName === "ConsoleUser");
console.log(user?.loginProfile?.password); // "hunter2"

A reference naming a String or a StringList parameter fails the resource, as real CloudFormation fails it. Read a parameter stored in the clear with a plain {{resolve:ssm:...}} reference.

An ssm-secure reference the simulation cannot answer

Section titled “An ssm-secure reference the simulation cannot answer”

The best-effort path is the one plain ssm references take. A reference naming a parameter that was never created, or a version the parameter never had, resolves to dummy-value-for-<name> and the stack carries on deploying. So does a body that is not a name and an optional integer version. Each substitution is recorded on stack.ignoredProperties.

Reading a parameter through a template Parameter

Section titled “Reading a parameter through a template Parameter”

A template Parameters entry declared as AWS::SSM::Parameter::Value<String> is given a parameter name, and Ref on it gives the value held under that name in the stack’s account and region. The name comes from the value passed to CreateStack, and from the template Default when no value is passed.

/**
* Reading configuration into a template through a Parameter Store value type.
*/
import { PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
await simAws.ssm().putParameter(
new PutParameterCommand({
Name: "/myapp/prod/uploads-bucket",
Type: "String",
Value: "myapp-prod-uploads",
}),
);
const stack = await simAws.cloudFormation().deployTemplate({
stackName: "app-stack",
template: {
Parameters: {
UploadsBucketName: {
Type: "AWS::SSM::Parameter::Value<String>",
Default: "/myapp/prod/uploads-bucket",
},
},
Resources: {
UploadsBucket: {
Type: "AWS::S3::Bucket",
Properties: { BucketName: { Ref: "UploadsBucketName" } },
},
},
},
});
await stack.waitForDeployComplete();
// The Bucket was created under the name the parameter holds.
console.log(simAws.s3().getSimBucketByName("myapp-prod-uploads")?.bucketName);
// "myapp-prod-uploads"

CDK emits this Parameter from ssm.StringParameter.valueForStringParameter(scope, name) called without a version, carrying the name as the Default.

AWS::SSM::Parameter::Value<List<String>> resolves to the stored comma-separated string split into a list, which Fn::Select then reads by index. AWS::SSM::Parameter::Value<CommaDelimitedList> resolves the same way. ssm.StringListParameter.fromListParameterAttributes without a version emits the first of the two.

The Parameters section is read before any resource is created, as it is on real AWS. A name a resource of the same stack goes on to create is never there in time, whatever the template says about DependsOn.

The stored value goes unchecked against the inner type. A name held against something other than an image ID resolves under AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>, where real CloudFormation refuses the stack.

The same best-effort answer a dynamic reference gets. A name Parameter Store has never held resolves to dummy-value-for-<name>, and the stack carries on deploying. A SecureString resolves that way too, since real CloudFormation refuses to read one into a template Parameter.

The substitution is recorded on stack.ignoredProperties. The logicalId names the template Parameter, the resourceType gives its declared type, and the path is Parameters.<parameter name>.

Function code that reads its configuration on cold start needs no special treatment. Any @aws-sdk/client-ssm client the handler creates is intercepted and dispatched with the function’s execution role as the caller. The role’s policy decides whether the read succeeds.

/**
* A simulated Lambda handler reading its configuration from Parameter Store.
*/
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { CreateFunctionCommand, InvokeCommand } from "@aws-sdk/client-lambda";
import { PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
import { makeLambdaCodeZip } from "@kensio/yulin/lambda";
const simAws = new SimAws();
const accountId = simAws.defaultAccountId;
const regionName = simAws.defaultRegionName;
await simAws.ssm().putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-host",
Type: "String",
Value: "db.internal",
}),
);
const role = await simAws.iam().createRole(
new CreateRoleCommand({
RoleName: "ConfigReaderRole",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: { Service: "lambda.amazonaws.com" },
Action: "sts:AssumeRole",
},
}),
}),
);
await simAws.iam().putRolePolicy(
new PutRolePolicyCommand({
RoleName: "ConfigReaderRole",
PolicyName: "ReadConfig",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Action: "ssm:GetParameter",
Resource: `arn:aws:ssm:${regionName}:${accountId}:parameter/myapp/prod/*`,
},
}),
}),
);
const handlerCode = [
'const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm");',
"exports.handler = async () => {",
" const client = new SSMClient({});",
' const command = new GetParameterCommand({ Name: "/myapp/prod/db-host" });',
" const out = await client.send(command);",
" return out.Parameter.Value;",
"};",
].join("\n");
const zipFile = makeLambdaCodeZip({ "index.js": handlerCode });
await simAws.lambda().createFunction(
new CreateFunctionCommand({
FunctionName: "config-reader",
Role: role.Role.Arn,
Handler: "index.handler",
Code: { ZipFile: zipFile },
}),
);
await simAws.backgroundTasksComplete();
const invoked = await simAws
.lambda()
.invoke(new InvokeCommand({ FunctionName: "config-reader" }));
console.log(Buffer.from(invoked.Payload ?? []).toString("utf8")); // "db.internal"

A parameter belongs to one account and region, as it does on real AWS. The same name in another scope is another parameter.

/**
* The same simulated parameter name in two Account and Region scopes.
*/
import { GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
await simAws
.account("111111111111")
.region("eu-west-2")
.ssm()
.putParameter(
new PutParameterCommand({
Name: "/myapp/db-host",
Type: "String",
Value: "eu.db.internal",
}),
);
await simAws
.account("222222222222")
.region("us-east-1")
.ssm()
.putParameter(
new PutParameterCommand({
Name: "/myapp/db-host",
Type: "String",
Value: "us.db.internal",
}),
);
const read = await simAws
.account("111111111111")
.region("eu-west-2")
.ssm()
.getParameter(new GetParameterCommand({ Name: "/myapp/db-host" }));
console.log(read.Parameter?.ARN);
// "arn:aws:ssm:eu-west-2:111111111111:parameter/myapp/db-host"

A SecureString value is encrypted through simulated KMS, under the aws/ssm AWS managed key unless the request names a key of its own. Simulated KMS creates that managed key the first time something asks for it, with no setup needed.

A read returns the ciphertext unless it asks for decryption. This is the mistake that is easy to make and hard to see. A handler that forgets WithDecryption parses a base64 blob as if it were a password.

/**
* Writing and reading a simulated SecureString parameter.
*/
import { GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const ssm = simAws.ssm();
await ssm.putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-password",
Type: "SecureString",
Value: "hunter2",
}),
);
const encrypted = await ssm.getParameter(
new GetParameterCommand({ Name: "/myapp/prod/db-password" }),
);
console.log(encrypted.Parameter?.Value); // a base64 ciphertext, not "hunter2"
const decrypted = await ssm.getParameter(
new GetParameterCommand({
Name: "/myapp/prod/db-password",
WithDecryption: true,
}),
);
console.log(decrypted.Parameter?.Value); // "hunter2"

WithDecryption on a String or StringList parameter is ignored, as real Parameter Store ignores it.

Pass KeyId to encrypt under a customer managed key instead. A KeyId naming a key that is absent, disabled, or pending deletion fails with InvalidKeyId. That is how real Parameter Store reports every KMS key problem.

Each value is bound to its own parameter’s ARN as the KMS encryption context, under the PARAMETER_ARN key. A ciphertext lifted out of one parameter cannot be decrypted as another.

A customer managed key needs its own permission

Section titled “A customer managed key needs its own permission”

Encrypting and decrypting go to simulated KMS as the caller, not as the service. Under a customer managed key a write needs kms:Encrypt on the key on top of ssm:PutParameter on the parameter, and a decrypting read needs kms:Decrypt on top of ssm:GetParameter. A role granted one and not the other fails here, ahead of a deployment.

/**
* A Role allowed to read a simulated SecureString but not to decrypt it.
*/
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { CreateKeyCommand } from "@aws-sdk/client-kms";
import { GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const accountId = simAws.defaultAccountId;
const key = await simAws
.kms()
.createKey(new CreateKeyCommand({ Description: "Parameter key" }));
await simAws.ssm().putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-password",
Type: "SecureString",
Value: "hunter2",
KeyId: key.KeyMetadata?.Arn,
}),
);
const role = await simAws.iam().createRole(
new CreateRoleCommand({
RoleName: "ConfigReader",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: { AWS: `arn:aws:iam::${accountId}:root` },
Action: "sts:AssumeRole",
},
}),
}),
);
// The parameter is allowed, the key is not.
await simAws.iam().putRolePolicy(
new PutRolePolicyCommand({
RoleName: "ConfigReader",
PolicyName: "ReadDbPassword",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Action: "ssm:GetParameter",
Resource: "*",
},
}),
}),
);
const caller = { kind: "arn", arn: role.Role.Arn } as const;
try {
await simAws.ssm().getParameter(
new GetParameterCommand({
Name: "/myapp/prod/db-password",
WithDecryption: true,
}),
{ caller },
);
} catch (error) {
console.log((error as Error).name); // "AccessDenied"
}

The aws/ssm managed key allows the read itself

Section titled “The aws/ssm managed key allows the read itself”

A parameter naming no key is encrypted under the aws/ssm AWS managed key. That key’s policy allows the cryptographic actions to a wildcard principal under kms:ViaService and kms:CallerAccount conditions, and Parameter Store satisfies both. A grant of that shape delegates nothing to IAM. A role holding ssm:GetParameter and nothing on KMS reads the decrypted value, as it does in an account, and withholding that access takes an explicit Deny.

GetParameters and GetParametersByPath decrypt through the same path and on the same permission. A SecureString write under the managed key asks for no KMS permission either.

A kms:Decrypt grant scoped with a kms:ViaService condition (the shape a stack writes when the key is chosen after synthesis) still works, and it is what a customer managed key wants.

The key stays out of reach of anything calling KMS directly. The same role handed the stored ciphertext cannot decrypt it through Decrypt, because the policy’s kms:ViaService condition matches only a request that arrived through Systems Manager. A caller in another account is refused by the kms:CallerAccount condition.

An ECS task resolves a secrets entry through this path. An execution role holding ssm:GetParameter alone reads a managed key SecureString into a container’s environment.

/**
* Reading a simulated SecureString under the aws/ssm managed key.
*/
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { GetParameterCommand, PutParameterCommand } from "@aws-sdk/client-ssm";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const accountId = simAws.defaultAccountId;
await simAws.ssm().putParameter(
new PutParameterCommand({
Name: "/myapp/prod/db-password",
Type: "SecureString",
Value: "hunter2",
}),
);
const role = await simAws.iam().createRole(
new CreateRoleCommand({
RoleName: "ConfigReader",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: { AWS: `arn:aws:iam::${accountId}:root` },
Action: "sts:AssumeRole",
},
}),
}),
);
// The parameter, and nothing on KMS.
await simAws.iam().putRolePolicy(
new PutRolePolicyCommand({
RoleName: "ConfigReader",
PolicyName: "ReadDbPassword",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: { Effect: "Allow", Action: "ssm:GetParameter", Resource: "*" },
}),
}),
);
const read = await simAws.ssm().getParameter(
new GetParameterCommand({
Name: "/myapp/prod/db-password",
WithDecryption: true,
}),
{ caller: { kind: "arn", arn: role.Role.Arn } },
);
console.log(read.Parameter?.Value); // "hunter2"

DescribeParameters reports the key each SecureString is encrypted under as KeyId.

Simulated Parameter Store supports:

  • PutParameterCommand, creating a parameter or overwriting one
  • GetParameterCommand, by name, by name:version or by name:label
  • GetParametersCommand, reporting names it could not resolve in InvalidParameters
  • GetParametersByPathCommand, with and without Recursive
  • DeleteParameterCommand and DeleteParametersCommand
  • DescribeParametersCommand
  • String, StringList and SecureString parameter types
  • SecureString values encrypted through simulated KMS, decrypted only with WithDecryption
  • The AWS::SSM::Parameter CloudFormation resource, including Ref and Fn::GetAtt
  • {{resolve:ssm:...}} dynamic references in CloudFormation resource properties, by version or by current value, embedded in a longer string and inside Fn::Sub
  • {{resolve:ssm-secure:...}} dynamic references, decrypting the SecureString through the KMS key it was written under, in the eleven resource properties CloudFormation reads one in
  • StringList parameters read through a dynamic reference as the comma-separated string Fn::Split then splits
  • AWS::SSM::Parameter::Value<String> template parameters, resolving Ref to the stored value
  • AWS::SSM::Parameter::Value<List<String>> and <CommaDelimitedList> template parameters, resolving Ref to the stored string split into a list
  • Parameter name validation, including hierarchy depth and the reserved aws and ssm prefixes
  • Authorization of every operation by simulated IAM, against the real IAM action and ARN
  • Calls made from inside a simulated Lambda handler, authorized as the function’s execution role
  • Only standard tier SecureString encryption is simulated, which encrypts under the KMS key directly. The advanced tier’s envelope encryption through the AWS Encryption SDK is left out, and kms:GenerateDataKey is never needed.
  • Parameter labels are left out. LabelParameterVersion is unimplemented, so a label can never be created, and a name:label selector is refused with an error saying so.
  • GetParameterHistory is absent, though earlier versions stay readable by number.
  • The advanced tier is left out. Tier: Advanced and Tier: Intelligent-Tiering are refused, and every parameter reports Tier: Standard with the 4KB standard tier value limit.
  • Parameter policies (expiration and notification) are left out. Policies is refused, and DescribeParameters always reports an empty Policies list.
  • Tags are left out. Tags on PutParameter is refused, and AddTagsToResource, RemoveTagsFromResource and ListTagsForResource are absent. An AWS::SSM::Parameter carrying Tags deploys with the tags dropped and the property recorded.
  • AllowedPattern is refused outright. Ignoring it would store a value it was meant to reject, without complaint.
  • KeyId on a String or StringList parameter is refused, since nothing would encrypt a value stored in the clear.
  • Reading and writing a SecureString under the aws/ssm managed key ask the caller for no KMS permission, which follows that key’s policy and the Parameter Store setup page. AWS’s SecureString encryption page documents kms:Encrypt and kms:Decrypt for either kind of key, and the two disagree. A parameter under a customer managed key does need them.
  • DataType other than text is refused. Real Parameter Store validates an aws:ec2:image value against EC2, which this simulation cannot do.
  • Filters are refused outright. GetParametersByPath refuses ParameterFilters, and DescribeParameters refuses Filters and ParameterFilters. Parameters are listed in name order.
  • DescribeParameters refuses Shared. Parameters cannot be shared between simulated accounts, and there is no resource policy support, so cross-account access to a parameter cannot be granted.
  • Every version is kept. Real Parameter Store keeps the hundred most recent versions and deletes the oldest as new ones are made, which can fail with ParameterMaxVersionLimitExceeded.
  • There is no per-account parameter count limit, so ParameterLimitExceeded never happens.
  • Deletion is immediate. Real Parameter Store asks for thirty seconds before a deleted name is reused, where here the name is free straight away.
  • AWS::SSM::Parameter supports Name, Type, Value, Description and Tier. AllowedPattern, DataType and Policies reach PutParameter, which refuses them for the reasons above. Tags is the one difference from the command, and is recorded as an ignored property so a stack that tags every Resource in it still deploys. Type: SecureString is refused, as real CloudFormation refuses it for this resource type. The plaintext value would sit in the template.
  • The other AWS::SSM::* resource types (Document, Association, MaintenanceWindow, PatchBaseline, ResourceDataSync and the rest) are reported as unsupported and skipped.
  • Every deployment of an AWS::SSM::Parameter is a create. A name another stack already used is refused. A stack update that changes Value deletes the parameter and creates it again, where real CloudFormation overwrites it in place, so the parameter’s version starts from 1 again.
  • A template parameter typed as AWS::SSM::Parameter::Value<...> is read once, while the Parameters section is. Real CloudFormation does the same, so a name that only exists once the stack has deployed resolves to a stand-in value here and fails the stack there.
  • The value a template parameter resolves to goes unvalidated against the inner type. The AWS::EC2::* and AWS::Route53::HostedZone::Id inner types name EC2 and Route53 resources, which this simulation holds none of.
  • A {{resolve:ssm:...}} or {{resolve:ssm-secure:...}} reference naming a parameter, or a version, that simulated Parameter Store has never held resolves to dummy-value-for-<name> and records the substitution. Real CloudFormation fails the stack. A template parameter naming one gets the same stand-in value.
  • An ssm-secure reference reads the stack’s own account and region. A parameter in another account is out of reach on real CloudFormation as well.
  • Public parameters under /aws/service/... do not exist, and names under the reserved aws and ssm prefixes are refused, as they are on real AWS.
  • The Parameters and Secrets Lambda extension HTTP endpoint is absent. Handler code has to use the SDK.
  • Systems Manager is otherwise left out. Run Command, Session Manager, Patch Manager, State Manager, Automation, inventory and maintenance windows are all absent.
  • SSM is not served as an HTTP API by serveSimAws.