Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@smithy/protocol-http": "^5.5.5",
"@smithy/signature-v4": "^5.6.1",
"aws-cdk-lib": "^2.260.0",
"aws-jwt-verify": "^5.2.1",
"cdk-nag": "^2.38.2",
"constructs": "^10.6.0",
"js-yaml": "^4.1.1",
Expand Down
96 changes: 96 additions & 0 deletions cdk/src/constructs/api-key-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* MIT No Attribution
*
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

import { RemovalPolicy } from 'aws-cdk-lib';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { Construct } from 'constructs';

/**
* Properties for ApiKeyTable construct.
*/
export interface ApiKeyTableProps {
/**
* Optional table name override.
* @default - auto-generated by CloudFormation
*/
readonly tableName?: string;

/**
* Removal policy for the table.
* @default RemovalPolicy.DESTROY
*/
readonly removalPolicy?: RemovalPolicy;

/**
* Whether to enable point-in-time recovery.
* @default true
*/
readonly pointInTimeRecovery?: boolean;
}

/**
* DynamoDB table for platform API keys (Cognito-free auth for headless clients).
*
* Schema: key_id (PK) with one GSI for querying by owner.
*
* The presented key is `bgak_<key_id>_<secret>`; the authorizer parses key_id
* and does a direct GetItem, so no secret-hash GSI is needed. Direct lookup by
* partition key also avoids GSI eventual-consistency, so a revoked key stops
* authenticating immediately.
*
* GSIs:
* - UserIndex (PK: user_id, SK: created_at) — list API keys for a user.
*/
export class ApiKeyTable extends Construct {
/**
* GSI name for querying API keys by owner.
* PK: user_id, SK: created_at.
*/
public static readonly USER_INDEX = 'UserIndex';

/**
* The underlying DynamoDB table.
*/
public readonly table: dynamodb.Table;

constructor(scope: Construct, id: string, props: ApiKeyTableProps = {}) {
super(scope, id);

this.table = new dynamodb.Table(this, 'Table', {
tableName: props.tableName,
partitionKey: {
name: 'key_id',
type: dynamodb.AttributeType.STRING,
},
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
timeToLiveAttribute: 'ttl',
pointInTimeRecoverySpecification: {
pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true,
},
removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY,
});

this.table.addGlobalSecondaryIndex({
indexName: ApiKeyTable.USER_INDEX,
partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING },
sortKey: { name: 'created_at', type: dynamodb.AttributeType.STRING },
projectionType: dynamodb.ProjectionType.ALL,
});
}
}
132 changes: 126 additions & 6 deletions cdk/src/constructs/task-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,20 @@ export interface TaskApiProps {
*/
readonly webhookTable?: dynamodb.ITable;

/**
* The DynamoDB platform API key table. When provided, API key management
* endpoints are created and the webhook management endpoints accept an API
* key (with `webhooks:manage`) in addition to a Cognito JWT.
*/
readonly apiKeyTable?: dynamodb.ITable;

/**
* Number of days to retain revoked API key records before DynamoDB TTL
* deletes them.
* @default 30
*/
readonly apiKeyRetentionDays?: number;

/**
* ARN of the orchestrator Lambda alias. When set, the create-task handler
* async-invokes the orchestrator after writing the task record.
Expand Down Expand Up @@ -198,10 +212,13 @@ export interface TaskApiProps {
* - GET /tasks/{task_id} → getTask (Cognito)
* - DELETE /tasks/{task_id} → cancelTask (Cognito)
* - GET /tasks/{task_id}/events → getTaskEvents (Cognito)
* - POST /webhooks → createWebhook (Cognito)
* - GET /webhooks → listWebhooks (Cognito)
* - DELETE /webhooks/{webhook_id} → deleteWebhook (Cognito)
* - POST /webhooks → createWebhook (Cognito, or JWT/API-key when apiKeyTable set)
* - GET /webhooks → listWebhooks (Cognito, or JWT/API-key when apiKeyTable set)
* - DELETE /webhooks/{webhook_id} → deleteWebhook (Cognito, or JWT/API-key when apiKeyTable set)
* - POST /webhooks/tasks → webhookCreateTask (REQUEST authorizer)
* - POST /api-keys → createApiKey (Cognito)
* - GET /api-keys → listApiKeys (Cognito)
* - DELETE /api-keys/{key_id} → deleteApiKey (Cognito)
*/
export class TaskApi extends Construct {
/**
Expand Down Expand Up @@ -984,6 +1001,95 @@ export class TaskApi extends Construct {
}
}

// --- Platform API key infrastructure (only when apiKeyTable is provided) ---
//
// Default: webhook management routes stay Cognito-only. When an API key
// table is wired, a unified REQUEST authorizer replaces Cognito on those
// routes so they accept EITHER a Cognito JWT OR a `webhooks:manage` API key
// (issue #376). Key *creation* stays Cognito-gated.
let webhookMgmtAuthOptions: apigw.MethodOptions = cognitoAuthOptions;

if (props.apiKeyTable) {
const apiKeyEnv: Record<string, string> = {
API_KEY_TABLE_NAME: props.apiKeyTable.tableName,
API_KEY_RETENTION_DAYS: String(props.apiKeyRetentionDays ?? DEFAULT_WEBHOOK_RETENTION_DAYS),
};

// --- Unified authorizer: Cognito JWT OR platform API key ---
const apiKeyAuthorizerFn = new lambda.NodejsFunction(this, 'ApiKeyAuthorizerFn', {
entry: path.join(handlersDir, 'api-key-authorizer.ts'),
handler: 'handler',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
environment: {
API_KEY_TABLE_NAME: props.apiKeyTable.tableName,
API_KEY_REQUIRED_SCOPE: 'webhooks:manage',
USER_POOL_ID: this.userPool.userPoolId,
APP_CLIENT_ID: this.appClient.userPoolClientId,
},
bundling: commonBundling,
});
props.apiKeyTable.grantReadData(apiKeyAuthorizerFn);

// No fixed identity source: a request carries EITHER Authorization OR
// X-API-Key, never a guaranteed one. Multiple identity sources are AND-ed
// and would short-circuit to 401 before the Lambda runs, so we leave them
// empty and disable caching (the Lambda decides on every request).
const webhookMgmtAuthorizer = new apigw.RequestAuthorizer(this, 'ApiKeyOrJwtAuthorizer', {
handler: apiKeyAuthorizerFn,
identitySources: [],
resultsCacheTtl: Duration.seconds(0),
});

webhookMgmtAuthOptions = {
authorizer: webhookMgmtAuthorizer,
authorizationType: apigw.AuthorizationType.CUSTOM,
requestValidator,
};

// --- API key management Lambdas (Cognito-authenticated — minting a key
// requires a real interactive user) ---
const createApiKeyFn = new lambda.NodejsFunction(this, 'CreateApiKeyFn', {
entry: path.join(handlersDir, 'create-api-key.ts'),
handler: 'handler',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
environment: apiKeyEnv,
bundling: commonBundling,
});

const listApiKeysFn = new lambda.NodejsFunction(this, 'ListApiKeysFn', {
entry: path.join(handlersDir, 'list-api-keys.ts'),
handler: 'handler',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
environment: apiKeyEnv,
bundling: commonBundling,
});

const deleteApiKeyFn = new lambda.NodejsFunction(this, 'DeleteApiKeyFn', {
entry: path.join(handlersDir, 'delete-api-key.ts'),
handler: 'handler',
runtime: Runtime.NODEJS_24_X,
architecture: Architecture.ARM_64,
environment: apiKeyEnv,
bundling: commonBundling,
});

props.apiKeyTable.grantReadWriteData(createApiKeyFn);
props.apiKeyTable.grantReadData(listApiKeysFn);
props.apiKeyTable.grantReadWriteData(deleteApiKeyFn);

const apiKeys = this.api.root.addResource('api-keys');
apiKeys.addMethod('POST', new apigw.LambdaIntegration(createApiKeyFn), cognitoAuthOptions);
apiKeys.addMethod('GET', new apigw.LambdaIntegration(listApiKeysFn), cognitoAuthOptions);

const apiKeyById = apiKeys.addResource('{key_id}');
apiKeyById.addMethod('DELETE', new apigw.LambdaIntegration(deleteApiKeyFn), cognitoAuthOptions);

allFunctions.push(apiKeyAuthorizerFn, createApiKeyFn, listApiKeysFn, deleteApiKeyFn);
}

// --- Webhook endpoints (only when webhookTable is provided) ---
if (props.webhookTable) {
const webhookEnv: Record<string, string> = {
Expand Down Expand Up @@ -1122,12 +1228,26 @@ export class TaskApi extends Construct {
};

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

const webhookById = webhooks.addResource('{webhook_id}');
webhookById.addMethod('DELETE', new apigw.LambdaIntegration(deleteWebhookFn), cognitoAuthOptions);
const deleteWebhookMethod = webhookById.addMethod('DELETE', new apigw.LambdaIntegration(deleteWebhookFn), webhookMgmtAuthOptions);

// When the unified authorizer is in use these methods are CUSTOM, not
// Cognito — suppress COG4 (the authorizer still enforces a Cognito JWT
// or a scoped API key; issue #376).
if (props.apiKeyTable) {
NagSuppressions.addResourceSuppressions([createWebhookMethod, listWebhooksMethod, deleteWebhookMethod], [
{
id: 'AwsSolutions-COG4',
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)',
},
]);
}

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