Skip to content

Commit 9993761

Browse files
authored
Move UIN hash pepper to SSM instead of secret (#449)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Application now retrieves configuration from AWS SSM Parameter Store alongside Secrets Manager. * **Tools** * Added a CLI utility to create/update secure SSM parameters across multiple regions. * **Documentation** * New guide for creating and managing SSM parameters across regions. * **Chores** * Updated AWS SDK dependencies to v3.922.0 and updated runtime permissions to allow SSM parameter access. * **Tests** * Test setup updated to mock SSM Parameter Store retrievals. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 6b5c7e0 commit 9993761

File tree

10 files changed

+1156
-144
lines changed

10 files changed

+1156
-144
lines changed

src/api/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import { docsHtml, securitySchemes } from "./docs.js";
6262
import syncIdentityPlugin from "./routes/syncIdentity.js";
6363
import { createRedisModule } from "./redis.js";
6464
import userRoute from "./routes/user.js";
65+
import { getSsmParameter } from "./utils.js";
66+
import { SSMClient } from "@aws-sdk/client-ssm";
6567
/** END ROUTES */
6668

6769
export const instanceId = randomUUID();
@@ -306,7 +308,25 @@ Otherwise, email [infra@acm.illinois.edu](mailto:infra@acm.illinois.edu) for sup
306308
getSecretValue(app.secretsManagerClient, secretName),
307309
),
308310
);
309-
app.secretConfig = allSecrets.reduce(
311+
app.log.debug(
312+
`Getting secure parameters (SSM): ${JSON.stringify(app.environmentConfig.ConfigurationParameterIds)}.`,
313+
);
314+
const ssmClient = new SSMClient({ region: genericConfig.AwsRegion });
315+
const allParameters = await Promise.all(
316+
app.environmentConfig.ConfigurationParameterIds.map(
317+
async (parameterName) => {
318+
const val = await getSsmParameter({
319+
parameterName,
320+
logger: app.log,
321+
ssmClient,
322+
});
323+
const key = parameterName.split("/").at(-1) || parameterName;
324+
return { [key]: val };
325+
},
326+
),
327+
);
328+
const allConfig = [...allSecrets, ...allParameters];
329+
app.secretConfig = allConfig.reduce(
310330
(acc, currentSecret) => ({ ...acc, ...currentSecret }),
311331
{},
312332
) as SecretConfig;

src/api/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
"prettier:write": "prettier --write *.ts **/*.ts"
1616
},
1717
"dependencies": {
18-
"@aws-sdk/s3-request-presigner": "^3.914.0",
19-
"@aws-sdk/client-s3": "^3.914.0",
18+
"@aws-sdk/s3-request-presigner": "^3.922.0",
19+
"@aws-sdk/client-s3": "^3.922.0",
2020
"@aws-sdk/client-dynamodb": "^3.922.0",
2121
"@aws-sdk/client-lambda": "^3.922.0",
2222
"@aws-sdk/client-secrets-manager": "^3.922.0",
2323
"@aws-sdk/client-ses": "^3.922.0",
2424
"@aws-sdk/client-sqs": "^3.922.0",
2525
"@aws-sdk/client-sts": "^3.922.0",
26+
"@aws-sdk/client-ssm": "^3.922.0",
2627
"@aws-sdk/signature-v4-crt": "^3.922.0",
2728
"@aws-sdk/util-dynamodb": "^3.922.0",
2829
"@azure/msal-node": "^3.8.1",

src/api/utils.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { FastifyBaseLogger } from "fastify";
2-
import pino from "pino";
1+
import { InternalServerError } from "common/errors/index.js";
32
import { ValidLoggers } from "./types.js";
3+
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
4+
import { genericConfig } from "common/config.js";
45

56
const MAX_RETRIES = 3;
67

@@ -43,4 +44,42 @@ export async function retryDynamoTransactionWithBackoff<T>(
4344
throw lastError;
4445
}
4546

47+
type GetSsmParameterInputs = {
48+
parameterName: string;
49+
logger: ValidLoggers;
50+
ssmClient?: SSMClient | undefined;
51+
};
52+
53+
export const getSsmParameter = async ({
54+
parameterName,
55+
logger,
56+
ssmClient,
57+
}: GetSsmParameterInputs) => {
58+
const client =
59+
ssmClient || new SSMClient({ region: genericConfig.AwsRegion });
60+
61+
const params = {
62+
Name: parameterName,
63+
WithDecryption: true,
64+
};
65+
66+
const command = new GetParameterCommand(params);
67+
68+
try {
69+
const data = await client.send(command);
70+
if (!data.Parameter || !data.Parameter.Value) {
71+
logger.error(`Parameter ${parameterName} not found`);
72+
throw new InternalServerError({ message: "Parameter not found" });
73+
}
74+
return data.Parameter.Value;
75+
} catch (error) {
76+
const errorMessage = error instanceof Error ? error.message : String(error);
77+
logger.error(
78+
`Error retrieving parameter ${parameterName}: ${errorMessage}`,
79+
error,
80+
);
81+
throw new InternalServerError({ message: "Failed to retrieve parameter" });
82+
}
83+
};
84+
4685
export const isProd = process.env.RunEnvironment === "prod";

src/common/config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type ConfigType = {
2626
PaidMemberPriceId: string;
2727
AadValidReadOnlyClientId: string;
2828
ConfigurationSecretIds: string[];
29+
ConfigurationParameterIds: string[];
2930
DiscordGuildId: string;
3031
GroupSuffix: string;
3132
GroupEmailSuffix: string;
@@ -60,7 +61,6 @@ export type GenericConfigType = {
6061
AuditLogTable: string;
6162
ApiKeyTable: string;
6263
ConfigSecretName: string;
63-
UinHashingSecret: string;
6464
UinExtendedAttributeName: string;
6565
UserInfoTable: string;
6666
SigInfoTableName: string;
@@ -105,7 +105,6 @@ const genericConfig: GenericConfigType = {
105105
AuditLogTable: "infra-core-api-audit-log",
106106
ApiKeyTable: "infra-core-api-keys",
107107
ConfigSecretName: "infra-core-api-config",
108-
UinHashingSecret: "infra-core-api-uin-pepper",
109108
UinExtendedAttributeName: "extension_a70c2e1556954056a6a8edfb1f42f556_uiucEduUIN",
110109
UserInfoTable: "infra-core-api-user-info",
111110
SigInfoTableName: "infra-core-api-sigs",
@@ -125,7 +124,8 @@ const environmentConfig: EnvironmentConfigType = {
125124
/^https?:\/\/([a-zA-Z0-9-]+\.)*acmuiuc\.workers\.dev$/,
126125
/http:\/\/localhost:\d+$/,
127126
],
128-
ConfigurationSecretIds: [genericConfig.ConfigSecretName, genericConfig.UinHashingSecret],
127+
ConfigurationSecretIds: [genericConfig.ConfigSecretName],
128+
ConfigurationParameterIds: ['/infra-core-api/UIN_HASHING_SECRET_PEPPER'],
129129
AadValidClientId: "39c28870-94e4-47ee-b4fb-affe0bf96c9f",
130130
LinkryBaseUrl: "https://core.aws.qa.acmuiuc.org",
131131
PasskitIdentifier: "pass.org.acmuiuc.qa.membership",
@@ -149,7 +149,9 @@ const environmentConfig: EnvironmentConfigType = {
149149
prod: {
150150
UserFacingUrl: "https://core.acm.illinois.edu",
151151
AzureRoleMapping: { AutonomousWriters: [AppRoles.EVENTS_MANAGER] },
152-
ConfigurationSecretIds: [genericConfig.ConfigSecretName, genericConfig.UinHashingSecret],
152+
// TODO: once SSM permission obtained in Prod, also move pepper to SSM
153+
ConfigurationSecretIds: [genericConfig.ConfigSecretName, 'infra-core-api-uin-pepper'],
154+
ConfigurationParameterIds: [],
153155
ValidCorsOrigins: [
154156
/^https:\/\/(?:.*\.)?acmuiuc-academic-web\.pages\.dev$/,
155157
/^https:\/\/(?:.*\.)?acmuiuc-digital-signage\.pages\.dev$/,

terraform/modules/lambdas/main.tf

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,22 @@ resource "aws_iam_policy" "shared_iam_policy" {
190190
Effect = "Allow",
191191
Resource = ["${aws_cloudwatch_log_group.api_logs.arn}:*"]
192192
},
193+
{
194+
Action = ["kms:Decrypt"],
195+
Effect = "Allow",
196+
Resource = ["arn:aws:kms:${var.region}:${data.aws_caller_identity.current.account_id}:alias/aws/ssm"]
197+
},
198+
{
199+
Action = [
200+
"ssm:GetParameter",
201+
"ssm:GetParameters",
202+
"ssm:GetParametersByPath"
203+
],
204+
Resource = [
205+
"arn:aws:ssm:${var.region}:${data.aws_caller_identity.current.account_id}:parameter/infra-core-api/*"
206+
],
207+
Effect = "Allow"
208+
},
193209
{
194210
Action = ["secretsmanager:GetSecretValue"],
195211
Effect = "Allow",

tests/unit/secret.testdata.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,6 @@ const secretObject = {
1818

1919
const secretJson = JSON.stringify(secretObject);
2020

21-
const uinSecretJson = JSON.stringify({
22-
UIN_HASHING_SECRET_PEPPER: "dc1f1a24-fce5-480b-a342-e7bd34d8f8c5",
23-
});
24-
2521
const jwtPayload = {
2622
aud: "custom_jwt",
2723
iss: "custom_jwt",
@@ -74,10 +70,4 @@ const jwtPayloadNoGroups = {
7470
ver: "1.0",
7571
};
7672

77-
export {
78-
secretJson,
79-
secretObject,
80-
jwtPayload,
81-
jwtPayloadNoGroups,
82-
uinSecretJson,
83-
};
73+
export { secretJson, secretObject, jwtPayload, jwtPayloadNoGroups };

tests/unit/vitest.setup.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ import {
88
import { mockClient } from "aws-sdk-client-mock";
99
import { marshall } from "@aws-sdk/util-dynamodb";
1010
import { genericConfig } from "../../src/common/config.js";
11-
import { secretJson, uinSecretJson } from "./secret.testdata.js";
11+
import { secretJson } from "./secret.testdata.js";
1212
import {
1313
UnauthenticatedError,
1414
ValidationError,
1515
} from "../../src/common/errors/index.js";
16+
import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";
1617

1718
const ddbMock = mockClient(DynamoDBClient);
1819
const smMock = mockClient(SecretsManagerClient);
20+
const ssmMock = mockClient(SSMClient);
21+
1922
vi.mock(
2023
import("../../src/api/functions/rateLimit.js"),
2124
async (importOriginal) => {
@@ -180,12 +183,21 @@ smMock.on(GetSecretValueCommand).callsFake((command) => {
180183
if (command.SecretId == genericConfig.ConfigSecretName) {
181184
return Promise.resolve({ SecretString: secretJson });
182185
}
183-
if (command.SecretId == genericConfig.UinHashingSecret) {
184-
return Promise.resolve({ SecretString: uinSecretJson });
185-
}
186186
return Promise.reject(new Error(`Secret ID ${command.SecretID} not mocked`));
187187
});
188188

189+
ssmMock.on(GetParameterCommand).callsFake((command) => {
190+
const ssmParameters: Record<string, string> = {
191+
"/infra-core-api/UIN_HASHING_SECRET_PEPPER":
192+
"dc1f1a24-fce5-480b-a342-e7bd34d8f8c5",
193+
};
194+
const value = ssmParameters[command.Name];
195+
if (value) {
196+
return Promise.resolve({ Parameter: { Value: value } });
197+
}
198+
return Promise.reject(new Error(`Parameter ${command.Name} not mocked`));
199+
});
200+
189201
vi.mock("ioredis", () => import("ioredis-mock"));
190202

191203
let mockCacheStore = new Map();

utils/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Utils
2+
3+
Random useful scripts
4+
5+
## Create Multiregion Parameter
6+
7+
Create the same AWS SSM ParameterStore Secure string in all regions we need.

utils/setSsmParameter.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env python3
2+
"""
3+
SSM Parameter Store Multi-Region Tool
4+
5+
Creates or updates a SecureString parameter across multiple AWS regions.
6+
"""
7+
8+
import boto3
9+
from botocore.exceptions import ClientError, NoCredentialsError
10+
import argparse
11+
import sys
12+
import getpass
13+
from concurrent.futures import ThreadPoolExecutor, as_completed
14+
15+
REGIONS = [
16+
"us-east-2",
17+
"us-west-2",
18+
]
19+
20+
21+
def put_parameter_in_region(region: str, name: str, value: str) -> dict:
22+
"""Create or update an SSM SecureString parameter in a specific region."""
23+
try:
24+
ssm = boto3.client("ssm", region_name=region)
25+
26+
response = ssm.put_parameter(
27+
Name=name,
28+
Value=value,
29+
Type="SecureString",
30+
Overwrite=True,
31+
)
32+
33+
return {
34+
"region": region,
35+
"success": True,
36+
"version": response.get("Version"),
37+
}
38+
39+
except ClientError as e:
40+
return {
41+
"region": region,
42+
"success": False,
43+
"error": e.response["Error"]["Message"],
44+
}
45+
except NoCredentialsError:
46+
return {
47+
"region": region,
48+
"success": False,
49+
"error": "AWS credentials not configured",
50+
}
51+
except Exception as e:
52+
return {
53+
"region": region,
54+
"success": False,
55+
"error": str(e),
56+
}
57+
58+
59+
def put_parameter_multi_region(name: str, value: str, regions: list[str] = None) -> list[dict]:
60+
"""Create or update an SSM SecureString parameter across multiple regions."""
61+
target_regions = regions or REGIONS
62+
results = []
63+
64+
with ThreadPoolExecutor(max_workers=len(target_regions)) as executor:
65+
futures = {
66+
executor.submit(put_parameter_in_region, region, name, value): region
67+
for region in target_regions
68+
}
69+
70+
for future in as_completed(futures):
71+
results.append(future.result())
72+
73+
return sorted(results, key=lambda x: x["region"])
74+
75+
76+
def main():
77+
parser = argparse.ArgumentParser(
78+
description="Create or update SSM SecureString parameter in multiple regions"
79+
)
80+
parser.add_argument("name", help="Parameter name (e.g., /app/database/password)")
81+
parser.add_argument(
82+
"--regions",
83+
nargs="+",
84+
help=f"Override default regions (default: {', '.join(REGIONS)})",
85+
)
86+
87+
args = parser.parse_args()
88+
89+
# Prompt for value securely (not shown in terminal or bash history)
90+
value = getpass.getpass("Enter parameter value: ")
91+
92+
if not value:
93+
print("Error: Parameter value cannot be empty")
94+
sys.exit(1)
95+
96+
target_regions = args.regions or REGIONS
97+
print(f"\nSetting parameter '{args.name}' in {len(target_regions)} region(s)...\n")
98+
99+
results = put_parameter_multi_region(args.name, value, target_regions)
100+
101+
successes = 0
102+
failures = 0
103+
104+
for result in results:
105+
if result["success"]:
106+
successes += 1
107+
print(f" ✓ {result['region']}: version {result['version']}")
108+
else:
109+
failures += 1
110+
print(f" ✗ {result['region']}: {result['error']}")
111+
112+
print(f"\nComplete: {successes} succeeded, {failures} failed")
113+
114+
sys.exit(0 if failures == 0 else 1)
115+
116+
117+
if __name__ == "__main__":
118+
main()

0 commit comments

Comments
 (0)