Skip to content

Commit 4693bc9

Browse files
authored
Merge branch 'main' into fix/615-ecs-scoped-session-hang
2 parents 6b73969 + 0ee7227 commit 4693bc9

35 files changed

Lines changed: 2282 additions & 23 deletions

cdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@smithy/protocol-http": "^5.5.5",
3333
"@smithy/signature-v4": "^5.6.1",
3434
"aws-cdk-lib": "^2.260.0",
35+
"aws-jwt-verify": "^5.2.1",
3536
"cdk-nag": "^2.38.2",
3637
"constructs": "^10.6.0",
3738
"js-yaml": "^4.1.1",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
import { RemovalPolicy } from 'aws-cdk-lib';
21+
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
22+
import { Construct } from 'constructs';
23+
24+
/**
25+
* Properties for ApiKeyTable construct.
26+
*/
27+
export interface ApiKeyTableProps {
28+
/**
29+
* Optional table name override.
30+
* @default - auto-generated by CloudFormation
31+
*/
32+
readonly tableName?: string;
33+
34+
/**
35+
* Removal policy for the table.
36+
* @default RemovalPolicy.DESTROY
37+
*/
38+
readonly removalPolicy?: RemovalPolicy;
39+
40+
/**
41+
* Whether to enable point-in-time recovery.
42+
* @default true
43+
*/
44+
readonly pointInTimeRecovery?: boolean;
45+
}
46+
47+
/**
48+
* DynamoDB table for platform API keys (Cognito-free auth for headless clients).
49+
*
50+
* Schema: key_id (PK) with one GSI for querying by owner.
51+
*
52+
* The presented key is `bgak_<key_id>_<secret>`; the authorizer parses key_id
53+
* and does a direct GetItem, so no secret-hash GSI is needed. Direct lookup by
54+
* partition key also avoids GSI eventual-consistency, so a revoked key stops
55+
* authenticating immediately.
56+
*
57+
* GSIs:
58+
* - UserIndex (PK: user_id, SK: created_at) — list API keys for a user.
59+
* Projects only the attributes the list response needs; `key_hash` is
60+
* deliberately excluded so the secret hash is never replicated into the index
61+
* (defense-in-depth — the list path already strips it, this stops it being
62+
* readable via the GSI at all).
63+
*/
64+
export class ApiKeyTable extends Construct {
65+
/**
66+
* GSI name for querying API keys by owner.
67+
* PK: user_id, SK: created_at.
68+
*/
69+
public static readonly USER_INDEX = 'UserIndex';
70+
71+
/**
72+
* The underlying DynamoDB table.
73+
*/
74+
public readonly table: dynamodb.Table;
75+
76+
constructor(scope: Construct, id: string, props: ApiKeyTableProps = {}) {
77+
super(scope, id);
78+
79+
this.table = new dynamodb.Table(this, 'Table', {
80+
tableName: props.tableName,
81+
partitionKey: {
82+
name: 'key_id',
83+
type: dynamodb.AttributeType.STRING,
84+
},
85+
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
86+
timeToLiveAttribute: 'ttl',
87+
pointInTimeRecoverySpecification: {
88+
pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true,
89+
},
90+
removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY,
91+
});
92+
93+
this.table.addGlobalSecondaryIndex({
94+
indexName: ApiKeyTable.USER_INDEX,
95+
partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING },
96+
sortKey: { name: 'created_at', type: dynamodb.AttributeType.STRING },
97+
// Everything the list response needs, minus `key_hash`. Key attributes
98+
// (key_id, user_id, created_at) are projected automatically.
99+
projectionType: dynamodb.ProjectionType.INCLUDE,
100+
nonKeyAttributes: ['name', 'status', 'scopes', 'updated_at', 'expires_at', 'revoked_at'],
101+
});
102+
}
103+
}

cdk/src/constructs/task-api.ts

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,20 @@ export interface TaskApiProps {
115115
*/
116116
readonly webhookTable?: dynamodb.ITable;
117117

118+
/**
119+
* The DynamoDB platform API key table. When provided, API key management
120+
* endpoints are created and the webhook management endpoints accept an API
121+
* key (with `webhooks:manage`) in addition to a Cognito JWT.
122+
*/
123+
readonly apiKeyTable?: dynamodb.ITable;
124+
125+
/**
126+
* Number of days to retain revoked API key records before DynamoDB TTL
127+
* deletes them.
128+
* @default 30
129+
*/
130+
readonly apiKeyRetentionDays?: number;
131+
118132
/**
119133
* ARN of the orchestrator Lambda alias. When set, the create-task handler
120134
* async-invokes the orchestrator after writing the task record.
@@ -198,10 +212,13 @@ export interface TaskApiProps {
198212
* - GET /tasks/{task_id} → getTask (Cognito)
199213
* - DELETE /tasks/{task_id} → cancelTask (Cognito)
200214
* - GET /tasks/{task_id}/events → getTaskEvents (Cognito)
201-
* - POST /webhooks → createWebhook (Cognito)
202-
* - GET /webhooks → listWebhooks (Cognito)
203-
* - DELETE /webhooks/{webhook_id} → deleteWebhook (Cognito)
215+
* - POST /webhooks → createWebhook (Cognito, or JWT/API-key when apiKeyTable set)
216+
* - GET /webhooks → listWebhooks (Cognito, or JWT/API-key when apiKeyTable set)
217+
* - DELETE /webhooks/{webhook_id} → deleteWebhook (Cognito, or JWT/API-key when apiKeyTable set)
204218
* - POST /webhooks/tasks → webhookCreateTask (REQUEST authorizer)
219+
* - POST /api-keys → createApiKey (Cognito)
220+
* - GET /api-keys → listApiKeys (Cognito)
221+
* - DELETE /api-keys/{key_id} → deleteApiKey (Cognito)
205222
*/
206223
export class TaskApi extends Construct {
207224
/**
@@ -1008,6 +1025,95 @@ export class TaskApi extends Construct {
10081025
}
10091026
}
10101027

1028+
// --- Platform API key infrastructure (only when apiKeyTable is provided) ---
1029+
//
1030+
// Default: webhook management routes stay Cognito-only. When an API key
1031+
// table is wired, a unified REQUEST authorizer replaces Cognito on those
1032+
// routes so they accept EITHER a Cognito JWT OR a `webhooks:manage` API key
1033+
// (issue #376). Key *creation* stays Cognito-gated.
1034+
let webhookMgmtAuthOptions: apigw.MethodOptions = cognitoAuthOptions;
1035+
1036+
if (props.apiKeyTable) {
1037+
const apiKeyEnv: Record<string, string> = {
1038+
API_KEY_TABLE_NAME: props.apiKeyTable.tableName,
1039+
API_KEY_RETENTION_DAYS: String(props.apiKeyRetentionDays ?? DEFAULT_WEBHOOK_RETENTION_DAYS),
1040+
};
1041+
1042+
// --- Unified authorizer: Cognito JWT OR platform API key ---
1043+
const apiKeyAuthorizerFn = new lambda.NodejsFunction(this, 'ApiKeyAuthorizerFn', {
1044+
entry: path.join(handlersDir, 'api-key-authorizer.ts'),
1045+
handler: 'handler',
1046+
runtime: Runtime.NODEJS_24_X,
1047+
architecture: Architecture.ARM_64,
1048+
environment: {
1049+
API_KEY_TABLE_NAME: props.apiKeyTable.tableName,
1050+
API_KEY_REQUIRED_SCOPE: 'webhooks:manage',
1051+
USER_POOL_ID: this.userPool.userPoolId,
1052+
APP_CLIENT_ID: this.appClient.userPoolClientId,
1053+
},
1054+
bundling: commonBundling,
1055+
});
1056+
props.apiKeyTable.grantReadData(apiKeyAuthorizerFn);
1057+
1058+
// No fixed identity source: a request carries EITHER Authorization OR
1059+
// X-API-Key, never a guaranteed one. Multiple identity sources are AND-ed
1060+
// and would short-circuit to 401 before the Lambda runs, so we leave them
1061+
// empty and disable caching (the Lambda decides on every request).
1062+
const webhookMgmtAuthorizer = new apigw.RequestAuthorizer(this, 'ApiKeyOrJwtAuthorizer', {
1063+
handler: apiKeyAuthorizerFn,
1064+
identitySources: [],
1065+
resultsCacheTtl: Duration.seconds(0),
1066+
});
1067+
1068+
webhookMgmtAuthOptions = {
1069+
authorizer: webhookMgmtAuthorizer,
1070+
authorizationType: apigw.AuthorizationType.CUSTOM,
1071+
requestValidator,
1072+
};
1073+
1074+
// --- API key management Lambdas (Cognito-authenticated — minting a key
1075+
// requires a real interactive user) ---
1076+
const createApiKeyFn = new lambda.NodejsFunction(this, 'CreateApiKeyFn', {
1077+
entry: path.join(handlersDir, 'create-api-key.ts'),
1078+
handler: 'handler',
1079+
runtime: Runtime.NODEJS_24_X,
1080+
architecture: Architecture.ARM_64,
1081+
environment: apiKeyEnv,
1082+
bundling: commonBundling,
1083+
});
1084+
1085+
const listApiKeysFn = new lambda.NodejsFunction(this, 'ListApiKeysFn', {
1086+
entry: path.join(handlersDir, 'list-api-keys.ts'),
1087+
handler: 'handler',
1088+
runtime: Runtime.NODEJS_24_X,
1089+
architecture: Architecture.ARM_64,
1090+
environment: apiKeyEnv,
1091+
bundling: commonBundling,
1092+
});
1093+
1094+
const deleteApiKeyFn = new lambda.NodejsFunction(this, 'DeleteApiKeyFn', {
1095+
entry: path.join(handlersDir, 'delete-api-key.ts'),
1096+
handler: 'handler',
1097+
runtime: Runtime.NODEJS_24_X,
1098+
architecture: Architecture.ARM_64,
1099+
environment: apiKeyEnv,
1100+
bundling: commonBundling,
1101+
});
1102+
1103+
props.apiKeyTable.grantReadWriteData(createApiKeyFn);
1104+
props.apiKeyTable.grantReadData(listApiKeysFn);
1105+
props.apiKeyTable.grantReadWriteData(deleteApiKeyFn);
1106+
1107+
const apiKeys = this.api.root.addResource('api-keys');
1108+
apiKeys.addMethod('POST', new apigw.LambdaIntegration(createApiKeyFn), cognitoAuthOptions);
1109+
apiKeys.addMethod('GET', new apigw.LambdaIntegration(listApiKeysFn), cognitoAuthOptions);
1110+
1111+
const apiKeyById = apiKeys.addResource('{key_id}');
1112+
apiKeyById.addMethod('DELETE', new apigw.LambdaIntegration(deleteApiKeyFn), cognitoAuthOptions);
1113+
1114+
allFunctions.push(apiKeyAuthorizerFn, createApiKeyFn, listApiKeysFn, deleteApiKeyFn);
1115+
}
1116+
10111117
// --- Webhook endpoints (only when webhookTable is provided) ---
10121118
if (props.webhookTable) {
10131119
const webhookEnv: Record<string, string> = {
@@ -1146,12 +1252,26 @@ export class TaskApi extends Construct {
11461252
};
11471253

11481254
// --- API resource tree: /webhooks ---
1255+
// Management routes use the unified authorizer when an API key table is
1256+
// wired (JWT or `webhooks:manage` key), else fall back to Cognito-only.
11491257
const webhooks = this.api.root.addResource('webhooks');
1150-
webhooks.addMethod('POST', new apigw.LambdaIntegration(createWebhookFn), cognitoAuthOptions);
1151-
webhooks.addMethod('GET', new apigw.LambdaIntegration(listWebhooksFn), cognitoAuthOptions);
1258+
const createWebhookMethod = webhooks.addMethod('POST', new apigw.LambdaIntegration(createWebhookFn), webhookMgmtAuthOptions);
1259+
const listWebhooksMethod = webhooks.addMethod('GET', new apigw.LambdaIntegration(listWebhooksFn), webhookMgmtAuthOptions);
11521260

11531261
const webhookById = webhooks.addResource('{webhook_id}');
1154-
webhookById.addMethod('DELETE', new apigw.LambdaIntegration(deleteWebhookFn), cognitoAuthOptions);
1262+
const deleteWebhookMethod = webhookById.addMethod('DELETE', new apigw.LambdaIntegration(deleteWebhookFn), webhookMgmtAuthOptions);
1263+
1264+
// When the unified authorizer is in use these methods are CUSTOM, not
1265+
// Cognito — suppress COG4 (the authorizer still enforces a Cognito JWT
1266+
// or a scoped API key; issue #376).
1267+
if (props.apiKeyTable) {
1268+
NagSuppressions.addResourceSuppressions([createWebhookMethod, listWebhooksMethod, deleteWebhookMethod], [
1269+
{
1270+
id: 'AwsSolutions-COG4',
1271+
reason: 'Webhook management uses a unified REQUEST authorizer accepting a Cognito JWT or a scoped platform API key — by design for headless automation (issue #376)',
1272+
},
1273+
]);
1274+
}
11551275

11561276
const webhookTasks = webhooks.addResource('tasks');
11571277
const webhookTasksMethod = webhookTasks.addMethod('POST', new apigw.LambdaIntegration(webhookCreateTaskFn), webhookAuthOptions);

0 commit comments

Comments
 (0)