diff --git a/bin/deploy-bamboo.sh b/bin/deploy-bamboo.sh index b41c29f26..a916b5ddf 100755 --- a/bin/deploy-bamboo.sh +++ b/bin/deploy-bamboo.sh @@ -67,7 +67,10 @@ EOF dockerTag=mmt-$bamboo_STAGE_NAME docker build -t $dockerTag . -# Convenience function to invoke `docker run` with appropriate env vars instead of baking them into image +# Convenience function to invoke `docker run` with appropriate env vars instead of baking them into image. +# The STAGING_TARGET_* vars are optional (the script runs under `set -u`): only define the +# bamboo_STAGING_TARGET_* plan variables in environments that forward staged concepts to +# another environment; they default to empty everywhere else, which disables forwarding. dockerRun() { docker run \ -e "AWS_ACCOUNT=$bamboo_AWS_ACCOUNT" \ @@ -76,6 +79,7 @@ dockerRun() { -e "AWS_SECRET_ACCESS_KEY=$bamboo_AWS_SECRET_ACCESS_KEY" \ -e "AWS_SESSION_TOKEN=$bamboo_AWS_SESSION_TOKEN" \ -e "COLLECTION_TEMPLATES_BUCKET_NAME=${bamboo_COLLECTION_TEMPLATES_BUCKET_NAME}" \ + -e "STAGING_CONCEPTS_BUCKET_NAME=${bamboo_STAGING_CONCEPTS_BUCKET_NAME}" \ -e "COOKIE_DOMAIN=$bamboo_COOKIE_DOMAIN" \ -e "DISPLAY_PROD_WARNING=$bamboo_DISPLAY_PROD_WARNING" \ -e "EDL_CLIENT_ID=$bamboo_EDL_CLIENT_ID" \ @@ -86,9 +90,13 @@ dockerRun() { -e "LOG_DESTINATION_ARN=$bamboo_LOG_DESTINATION_ARN" \ -e "MMT_HOST=$bamboo_MMT_HOST" \ -e "NODE_ENV=production" \ + -e "STAGING_TARGET_API_HOST=${bamboo_STAGING_TARGET_API_HOST:-}" \ + -e "STAGING_TARGET_MMT_HOST=${bamboo_STAGING_TARGET_MMT_HOST:-}" \ + -e "STAGING_TARGET_API_KEY=${bamboo_STAGING_TARGET_API_KEY:-}" \ -e "NODE_OPTIONS=--max_old_space_size=4096" \ -e "SITE_BUCKET=${bamboo_SITE_BUCKET}" \ -e "STAGE_NAME=$bamboo_STAGE_NAME" \ + -e "STAGING_API_KEY=$bamboo_STAGING_API_KEY" \ -e "SUBNET_ID_A=$bamboo_SUBNET_ID_A" \ -e "SUBNET_ID_B=$bamboo_SUBNET_ID_B" \ -e "SUBNET_ID_C=$bamboo_SUBNET_ID_C" \ diff --git a/cdk/mmt/lib/mmt-authorizers.ts b/cdk/mmt/lib/mmt-authorizers.ts index 5023f49ee..be39dde7a 100644 --- a/cdk/mmt/lib/mmt-authorizers.ts +++ b/cdk/mmt/lib/mmt-authorizers.ts @@ -8,6 +8,7 @@ import { application } from '@edsc/cdk-utils' export interface MmtAuthorizersProps { apiGatewayRestApi: cdk.aws_apigateway.CfnRestApi; defaultLambdaConfig: application.NodeJsFunctionProps; + stagingApiKey: string; } /** @@ -17,52 +18,88 @@ export interface MmtAuthorizersProps { export class MmtAuthorizers extends Construct { public readonly edlAuthorizer: apigateway.CfnAuthorizer + public readonly stagingApiKeyAuthorizer: apigateway.CfnAuthorizer + constructor(scope: cdk.Stack, id: string, props: MmtAuthorizersProps) { super(scope, id) - const { apiGatewayRestApi, defaultLambdaConfig } = props + const { apiGatewayRestApi, defaultLambdaConfig, stagingApiKey } = props const functionNamePrefix = scope.stackName - const edlAuthorizerNestedStack = new cdk.NestedStack(scope, 'EdlAuthorizerNestedStack') - const { lambdaFunction: edlAuthorizerLambda } = new application.NodeJsFunction(edlAuthorizerNestedStack, 'EdlAuthorizerLambda', { - ...defaultLambdaConfig, - entry: '../../serverless/src/edlAuthorizer/handler.js', - functionName: 'edlAuthorizer', - functionNamePrefix - }) + const makeRequestAuthorizer = ( + nestedStackId: string, + lambdaId: string, + authorizerId: string, + functionName: string, + entry: string, + identitySource: string, + extraEnvironment: { [key: string]: string } = {} + ) => { + const nestedStack = new cdk.NestedStack(scope, nestedStackId) + + const { lambdaFunction } = new application.NodeJsFunction(nestedStack, lambdaId, { + ...defaultLambdaConfig, + entry, + environment: { + ...defaultLambdaConfig.environment, + ...extraEnvironment + }, + functionName, + functionNamePrefix + }) + + new lambda.CfnPermission(scope, `${lambdaId}PermissionApiGateway`, { + functionName: lambdaFunction.functionName, + action: 'lambda:InvokeFunction', + principal: 'apigateway.amazonaws.com', + sourceArn: [ + 'arn:', + scope.partition, + ':execute-api:', + scope.region, + ':', + scope.account, + ':', + apiGatewayRestApi.ref, + '/*/*' + ].join('') + }) + + return new apigateway.CfnAuthorizer(nestedStack, authorizerId, { + authorizerResultTtlInSeconds: 0, + authorizerUri: cdk.Fn.join('', [ + 'arn:', + cdk.Aws.PARTITION, + ':apigateway:', + cdk.Aws.REGION, + ':lambda:path/2015-03-31/functions/', + lambdaFunction.functionArn, + '/invocations' + ]), + identitySource, + name: functionName, + restApiId: apiGatewayRestApi.ref, + type: 'REQUEST' + }) + } - new lambda.CfnPermission(scope, 'EdlAuthorizerLambdaPermissionApiGateway', { - functionName: edlAuthorizerLambda.functionName, - action: 'lambda:InvokeFunction', - principal: 'apigateway.amazonaws.com', - sourceArn: [ - 'arn:', - scope.partition, - ':execute-api:', - scope.region, - ':', - scope.account, - ':', - apiGatewayRestApi.ref, - '/*/*' - ].join('') - }) + this.edlAuthorizer = makeRequestAuthorizer( + 'EdlAuthorizerNestedStack', + 'EdlAuthorizerLambda', + 'EdlAuthorizer', + 'edlAuthorizer', + '../../serverless/src/edlAuthorizer/handler.js', + 'method.request.header.Authorization' + ) - this.edlAuthorizer = new apigateway.CfnAuthorizer(edlAuthorizerNestedStack, 'EdlAuthorizer', { - authorizerResultTtlInSeconds: 0, - authorizerUri: cdk.Fn.join('', [ - 'arn:', - cdk.Aws.PARTITION, - ':apigateway:', - cdk.Aws.REGION, - ':lambda:path/2015-03-31/functions/', - edlAuthorizerLambda.functionArn, - '/invocations' - ]), - identitySource: 'method.request.header.Authorization', - name: 'edlAuthorizer', - restApiId: apiGatewayRestApi.ref, - type: 'REQUEST' - }) + this.stagingApiKeyAuthorizer = makeRequestAuthorizer( + 'StagingApiKeyAuthorizerNestedStack', + 'StagingApiKeyAuthorizerLambda', + 'StagingApiKeyAuthorizer', + 'stagingApiKeyAuthorizer', + '../../serverless/src/stagingApiKeyAuthorizer/handler.js', + 'method.request.header.Staging-Api-Key', + { STAGING_API_KEY: stagingApiKey } + ) } } diff --git a/cdk/mmt/lib/mmt-functions.ts b/cdk/mmt/lib/mmt-functions.ts index c077af3e0..b58bc3d4f 100644 --- a/cdk/mmt/lib/mmt-functions.ts +++ b/cdk/mmt/lib/mmt-functions.ts @@ -12,6 +12,7 @@ export interface MmtFunctionsProps { apiGatewayRestApi: cdk.aws_apigateway.CfnRestApi; authorizers: { edlAuthorizer: apigateway.CfnAuthorizer; + stagingApiKeyAuthorizer: apigateway.CfnAuthorizer; }; // MMT keeps explicit CORS config so API Gateway OPTIONS responses can control: // - allowOrigin: which browser origin can call the API @@ -26,6 +27,11 @@ export interface MmtFunctionsProps { allowHeaders: string[]; }; defaultLambdaConfig: application.NodeJsFunctionProps; + stagingTargetConfig: { + STAGING_TARGET_API_HOST: string; + STAGING_TARGET_MMT_HOST: string; + STAGING_TARGET_API_KEY: string; + }; s3LambdaRole: iam.IRole; } @@ -43,6 +49,7 @@ export class MmtFunctions extends Construct { authorizers, corsConfig, defaultLambdaConfig, + stagingTargetConfig, s3LambdaRole } = props @@ -250,5 +257,80 @@ export class MmtFunctions extends Construct { functionNamePrefix, role: s3LambdaRole }) + + // getStagedConcept - GET /staged/{conceptType}/{recordId} + new application.NodeJsFunction(new cdk.NestedStack(scope, 'GetStagedConceptNestedStack'), 'GetStagedConceptLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayResource: resources.stagedConceptTypeRecordIdResource, + apiGatewayRestApi, + authorizer: authorizers.edlAuthorizer, + methods: ['GET'], + parentPath: 'stagedConceptTypeVar', + path: '{recordId}' + }, + entry: '../../serverless/src/getStagedConcept/handler.js', + functionName: 'getStagedConcept', + functionNamePrefix, + role: s3LambdaRole + }) + + // createStagedConcept - PUT /staged/{conceptType} + new application.NodeJsFunction(new cdk.NestedStack(scope, 'CreateStagedConceptNestedStack'), 'CreateStagedConceptLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayResource: resources.stagedConceptTypeResource, + apiGatewayRestApi, + authorizer: authorizers.stagingApiKeyAuthorizer, + methods: ['PUT'], + parentPath: 'staged', + path: '{conceptType}' + }, + entry: '../../serverless/src/createStagedConcept/handler.js', + functionName: 'createStagedConcept', + functionNamePrefix, + role: s3LambdaRole + }) + + // deleteStagedConcept - DELETE /staged/{conceptType}/{recordId} + new application.NodeJsFunction(new cdk.NestedStack(scope, 'DeleteStagedConceptNestedStack'), 'DeleteStagedConceptLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayResource: resources.stagedConceptTypeRecordIdResource, + apiGatewayRestApi, + authorizer: authorizers.edlAuthorizer, + methods: ['DELETE'], + parentPath: 'stagedConceptTypeVar', + path: '{recordId}' + }, + entry: '../../serverless/src/deleteStagedConcept/handler.js', + functionName: 'deleteStagedConcept', + functionNamePrefix, + role: s3LambdaRole + }) + + // stageConceptForProduction - POST /providers/{providerId}/{conceptType}/stage-for-production + new application.NodeJsFunction(new cdk.NestedStack(scope, 'StageConceptForProductionNestedStack'), 'StageConceptForProductionLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayResource: resources.providersConceptTypeStageForProductionResource, + apiGatewayRestApi, + authorizer: authorizers.edlAuthorizer, + methods: ['POST'], + parentPath: 'providersProviderIdVarConceptTypeVar', + path: 'stage-for-production' + }, + entry: '../../serverless/src/stageConceptForProduction/handler.js', + environment: { + ...defaultLambdaConfig.environment, + ...stagingTargetConfig + }, + functionName: 'stageConceptForProduction', + functionNamePrefix + }) } } diff --git a/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts b/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts index f554a3241..fc946b282 100644 --- a/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts +++ b/cdk/mmt/lib/mmt-shared-api-gateway-resources.ts @@ -25,6 +25,10 @@ export class MmtApiResources extends Construct { public readonly errorLoggerResource: apigateway.CfnResource public readonly gkrKeywordRecommendationsResource: apigateway.CfnResource public readonly gkrSendFeedbackResource: apigateway.CfnResource + public readonly providersConceptTypeResource: apigateway.CfnResource + public readonly providersConceptTypeStageForProductionResource: apigateway.CfnResource + public readonly stagedConceptTypeResource: apigateway.CfnResource + public readonly stagedConceptTypeRecordIdResource: apigateway.CfnResource public readonly providersTemplatesResource: apigateway.CfnResource public readonly providersTemplatesIdResource: apigateway.CfnResource public readonly templatesResource: apigateway.CfnResource @@ -118,6 +122,42 @@ export class MmtApiResources extends Construct { }) this.providersTemplatesIdResource = providersTemplatesIdResource + // `/providers/{providerId}/{conceptType}` now exists only as the parent of + // the `stage-for-production` action route. The S3-backed concept routes + // (create/list/get/delete) live under `/staged/...` instead. + const providersConceptTypeResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceProvidersProviderIdVarConceptTypeVar', { + parentId: providerIdResource.ref, + pathPart: '{conceptType}', + restApiId: apiGatewayRestApi.ref + }) + this.providersConceptTypeResource = providersConceptTypeResource + + const providersConceptTypeStageForProductionResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceProvidersProviderIdVarConceptTypeVarStageForProduction', { + parentId: providersConceptTypeResource.ref, + pathPart: 'stage-for-production', + restApiId: apiGatewayRestApi.ref + }) + this.providersConceptTypeStageForProductionResource = providersConceptTypeStageForProductionResource + + // Staged concepts are opaque promotion artifacts keyed by a generated + // `recordId`; they carry no provider/native identity, so they route under a + // dedicated `/staged/{conceptType}` tree rather than under `/providers`. + const stagedResource = makeRootResource('Staged', 'staged') + + const stagedConceptTypeResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceStagedConceptTypeVar', { + parentId: stagedResource.ref, + pathPart: '{conceptType}', + restApiId: apiGatewayRestApi.ref + }) + this.stagedConceptTypeResource = stagedConceptTypeResource + + const stagedConceptTypeRecordIdResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceStagedConceptTypeVarRecordIdVar', { + parentId: stagedConceptTypeResource.ref, + pathPart: '{recordId}', + restApiId: apiGatewayRestApi.ref + }) + this.stagedConceptTypeRecordIdResource = stagedConceptTypeRecordIdResource + const templatesResource = new apigateway.CfnResource(scope, 'ApiGatewayResourceTemplates', { parentId: apiGatewayRestApi.attrRootResourceId, pathPart: 'templates', @@ -140,5 +180,13 @@ export class MmtApiResources extends Construct { addOptions('TemplatesIdVar', templatesIdResource, ['GET']) addOptions('Templates', templatesResource, ['GET']) + + addOptions('ProvidersProviderIdVarConceptTypeVarStageForProduction', providersConceptTypeStageForProductionResource, ['POST']) + + // `/staged/{conceptType}/{recordId}` — the browser-facing GET/DELETE routes. + addOptions('StagedConceptTypeVarRecordIdVar', stagedConceptTypeRecordIdResource, ['GET', 'DELETE']) + + // No OPTIONS for `/staged/{conceptType}` on purpose: its only method is the + // server-to-server PUT (createStagedConcept), which no browser calls. } } diff --git a/cdk/mmt/lib/mmt-stack.ts b/cdk/mmt/lib/mmt-stack.ts index 680a3aa2c..8beb6c87c 100644 --- a/cdk/mmt/lib/mmt-stack.ts +++ b/cdk/mmt/lib/mmt-stack.ts @@ -3,6 +3,7 @@ import * as cdk from 'aws-cdk-lib' import * as ec2 from 'aws-cdk-lib/aws-ec2' import * as iam from 'aws-cdk-lib/aws-iam' import * as lambda from 'aws-cdk-lib/aws-lambda' +import * as s3 from 'aws-cdk-lib/aws-s3' import { application } from '@edsc/cdk-utils' @@ -14,6 +15,11 @@ export interface MmtStackProps extends cdk.StackProps {} const { STAGE_NAME = 'dev', COLLECTION_TEMPLATES_BUCKET_NAME = `mmt-${STAGE_NAME}-collection-templates`, + STAGING_CONCEPTS_BUCKET_NAME = `mmt-${STAGE_NAME}-staging-concepts`, + STAGING_API_KEY = 'local-staging-api-key', + STAGING_TARGET_API_HOST = '', + STAGING_TARGET_MMT_HOST = '', + STAGING_TARGET_API_KEY = 'local-staging-api-key', COOKIE_DOMAIN = '.localhost', EDL_CLIENT_ID = '', EDL_PASSWORD = '', @@ -33,6 +39,28 @@ const { const runtime = lambda.Runtime.NODEJS_20_X const INFRA_EXPORT_PREFIX = 'cdk' +const LOCAL_STAGING_API_KEY_PLACEHOLDER = 'local-staging-api-key' + +// deploy-bamboo.sh forces NODE_ENV=production for every deployed stage (not just +// PROD); local synth never sets it. So this is "is this a real deployment?". +const isDeployedEnvironment = NODE_ENV === 'production' + +const isMissingOrPlaceholder = (value: string) => !value || value === LOCAL_STAGING_API_KEY_PLACEHOLDER + +if (isDeployedEnvironment) { + if (isMissingOrPlaceholder(STAGING_API_KEY)) { + throw new Error('STAGING_API_KEY must be set to a non-placeholder value for deployed environments') + } + + if (STAGING_TARGET_API_HOST && isMissingOrPlaceholder(STAGING_TARGET_API_KEY)) { + throw new Error('STAGING_TARGET_API_KEY must be set to a non-placeholder value when STAGING_TARGET_API_HOST is configured') + } + + if (STAGING_TARGET_API_HOST && !STAGING_TARGET_MMT_HOST) { + throw new Error('STAGING_TARGET_MMT_HOST must be set when STAGING_TARGET_API_HOST is configured') + } +} + const allowHeaders = [ 'Access-Control-Allow-Origin', 'Access-Control-Allow-Credentials', @@ -40,6 +68,7 @@ const allowHeaders = [ 'Access-Control-Request-Methods', 'Authorization', 'Origin', + 'Staging-Api-Key', 'User-Agent' ] @@ -78,8 +107,13 @@ export class MmtStack extends cdk.Stack { const { apiGatewayDeployment, apiGatewayRestApi } = apiGateway + // Shared environment for every Lambda. The staging API keys are deliberately + // NOT here - they are the credentials guarding the machine-to-machine + // concept routes, so they are passed only to the handlers that need them + // (see `stagingApiKey` and `stagingTargetConfig` below). const environment = { COLLECTION_TEMPLATES_BUCKET_NAME, + STAGING_CONCEPTS_BUCKET_NAME, COOKIE_DOMAIN, EDL_CLIENT_ID, EDL_PASSWORD, @@ -89,6 +123,14 @@ export class MmtStack extends cdk.Stack { NODE_OPTIONS: '--enable-source-maps' } + const stagingApiKey = STAGING_API_KEY + + const stagingTargetConfig = { + STAGING_TARGET_API_HOST, + STAGING_TARGET_MMT_HOST, + STAGING_TARGET_API_KEY + } + const defaultLambdaConfig: application.NodeJsFunctionProps = { bundling: { // Bundle runtime dependencies into the Lambda artifact. @@ -136,9 +178,23 @@ export class MmtStack extends cdk.Stack { resources: ['*'] })) + // eslint-disable-next-line no-new + new s3.Bucket(this, 'StagingConceptsBucket', { + bucketName: STAGING_CONCEPTS_BUCKET_NAME, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + removalPolicy: cdk.RemovalPolicy.RETAIN, + lifecycleRules: [{ + id: 'expire-staged-concepts', + enabled: true, + expiration: cdk.Duration.days(30) + }] + }) + const authorizers = new MmtAuthorizers(this, 'Authorizers', { apiGatewayRestApi, - defaultLambdaConfig + defaultLambdaConfig, + stagingApiKey }) // eslint-disable-next-line no-new @@ -146,7 +202,8 @@ export class MmtStack extends cdk.Stack { apiGatewayDeployment, apiGatewayRestApi, authorizers: { - edlAuthorizer: authorizers.edlAuthorizer + edlAuthorizer: authorizers.edlAuthorizer, + stagingApiKeyAuthorizer: authorizers.stagingApiKeyAuthorizer }, corsConfig: { allowCredentials: true, @@ -154,6 +211,7 @@ export class MmtStack extends cdk.Stack { allowOrigin: MMT_HOST }, defaultLambdaConfig, + stagingTargetConfig, s3LambdaRole: iamRoleCustomResourcesLambdaExecution }) diff --git a/docs/stage-for-production-env-vars.md b/docs/stage-for-production-env-vars.md new file mode 100644 index 000000000..a03d34c33 --- /dev/null +++ b/docs/stage-for-production-env-vars.md @@ -0,0 +1,85 @@ +# Stage-for-Production: environment variables per environment + +The cross-environment "Stage for Production" promotion flow (`MMT-4199`) introduces +five environment variables. This document describes how to set them across the +three deployed environments — **SIT**, **UAT**, and **PROD** — where SIT is a test +bed and UAT → PROD is the real promotion path. + +## The variables + +| Variable | Role | Bamboo plan variable | Required? | +|---|---|---|---| +| `STAGING_API_KEY` | **Inbound** secret this environment accepts on the `Staging-Api-Key` header | `bamboo_STAGING_API_KEY` (secret) | **always required** (deployed environments) | +| `STAGING_CONCEPTS_BUCKET_NAME` | This environment's concepts bucket — leave at the `mmt-${STAGE_NAME}-staging-concepts` default | `bamboo_STAGING_CONCEPTS_BUCKET_NAME` | **always required** | +| `STAGING_TARGET_API_HOST` | **Outbound** — API Gateway base URL the `stageConceptForProduction` Lambda `PUT`s to | `bamboo_STAGING_TARGET_API_HOST` | optional — **the on/off switch.** Leave undefined to disable forwarding from this environment; empty ⇒ the handler returns `500` | +| `STAGING_TARGET_MMT_HOST` | UI host used to build the deep link returned to the browser (`stagedConceptLink` in the response) | `bamboo_STAGING_TARGET_MMT_HOST` | required *only if* `STAGING_TARGET_API_HOST` is set — otherwise leave undefined too | +| `STAGING_TARGET_API_KEY` | **Outbound** secret sent to the target environment; must equal the target's `STAGING_API_KEY` | `bamboo_STAGING_TARGET_API_KEY` (secret) | required *only if* `STAGING_TARGET_API_HOST` is set — otherwise leave undefined too | + +**The three `STAGING_TARGET_*` variables are optional only as a group, not +individually.** Leave all three undefined to disable forwarding from this environment — +`deploy-bamboo.sh` defaults each to empty (`${bamboo_STAGING_TARGET_*:-}`), so a plan that +doesn't forward can skip them entirely. But the moment you set `STAGING_TARGET_API_HOST` +to turn forwarding *on*, the other two become mandatory, and this is enforced twice: + +- **At deploy time:** `cdk synth` (`cdk/mmt/lib/mmt-stack.ts`) throws if + `STAGING_TARGET_API_HOST` is set while `STAGING_TARGET_MMT_HOST` is missing, or while + `STAGING_TARGET_API_KEY` is missing or still the placeholder — so a half-configured + Bamboo plan fails the deploy instead of shipping broken. +- **At request time:** `stageConceptForProduction` also fails closed with a `500` if any of + the three is missing, as a second line of defense (e.g. if the guard were ever + bypassed, or a value went empty after deploy). + +So there are really only two valid states per environment: **all three unset** (no +forwarding), or **all three set together**. + +## Per environment + +### PROD — final destination (receives, never forwards) + +| Variable | Value | +|---|---| +| `STAGING_API_KEY` | `` — real, unique, non-placeholder. This is the key UAT uses to push in. | +| `STAGING_CONCEPTS_BUCKET_NAME` | default (`mmt-prod-staging-concepts`) | +| `STAGING_TARGET_API_HOST` | leave undefined — PROD is a forwarding target, not a sender | +| `STAGING_TARGET_MMT_HOST` | leave undefined (see above: only required when `STAGING_TARGET_API_HOST` is set) | +| `STAGING_TARGET_API_KEY` | leave undefined (see above) | + +### UAT — real promotion source → PROD + +| Variable | Value | +|---|---| +| `STAGING_API_KEY` | `` — real, unique. Used if you also test SIT → UAT, and good hygiene regardless. | +| `STAGING_CONCEPTS_BUCKET_NAME` | default (`mmt-uat-staging-concepts`) | +| `STAGING_TARGET_API_HOST` | PROD's API Gateway base URL | +| `STAGING_TARGET_MMT_HOST` | PROD's MMT UI host | +| `STAGING_TARGET_API_KEY` | **exactly** PROD's `STAGING_API_KEY` | + +### SIT — test bed + +SIT has two viable configurations for what it promotes into. + +**Option A — SIT → UAT (recommended).** Exercises the real +cross-account / VPC → private-API-Gateway path, which is the riskiest part of the +flow. Downside: it writes transient staged concepts into UAT's bucket (they +self-expire after 30 days). + +| Variable | Value | +|---|---| +| `STAGING_API_KEY` | `` — real, unique | +| `STAGING_CONCEPTS_BUCKET_NAME` | default (`mmt-sit-staging-concepts`) | +| `STAGING_TARGET_API_HOST` | UAT's API Gateway base URL | +| `STAGING_TARGET_MMT_HOST` | UAT's MMT UI host | +| `STAGING_TARGET_API_KEY` | **exactly** UAT's `STAGING_API_KEY` | + +**Option B — SIT → SIT loopback.** Self-contained, does not touch UAT, but does +**not** test cross-account networking — the forwarding Lambda calls its own +environment's API Gateway. + +| Variable | Value | +|---|---| +| `STAGING_API_KEY` | `` | +| `STAGING_CONCEPTS_BUCKET_NAME` | default (`mmt-sit-staging-concepts`) | +| `STAGING_TARGET_API_HOST` | SIT's own API Gateway base URL | +| `STAGING_TARGET_MMT_HOST` | SIT's own MMT UI host | +| `STAGING_TARGET_API_KEY` | SIT's own `STAGING_API_KEY` (same value) | + diff --git a/serverless/src/createStagedConcept/__tests__/handler.test.js b/serverless/src/createStagedConcept/__tests__/handler.test.js new file mode 100644 index 000000000..b60570b5a --- /dev/null +++ b/serverless/src/createStagedConcept/__tests__/handler.test.js @@ -0,0 +1,98 @@ +import { mockClient } from 'aws-sdk-client-mock' +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' + +import createStagedConcept from '../handler' + +const s3ClientMock = mockClient(S3Client) + +// `uuid` is globally mocked to return 'mock-uuid' (see test-setup.js) +const mockRecordId = 'mock-uuid' + +beforeEach(() => { + vi.clearAllMocks() + s3ClientMock.reset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('createStagedConcept', () => { + test('saves the concept to s3 under a generated recordId', async () => { + s3ClientMock.on(PutObjectCommand).resolves({ + $metadata: { + httpStatusCode: 200, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + }, + ETag: '"1a7e08244b933e4fea1f920da4988500"' + }) + + const event = { + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections' + } + } + + const response = await createStagedConcept(event) + + expect(response.statusCode).toBe(200) + + expect(JSON.parse(response.body)).toEqual({ + recordId: mockRecordId + }) + + const putCalls = s3ClientMock.commandCalls(PutObjectCommand) + expect(putCalls).toHaveLength(1) + expect(putCalls[0].args[0].input.Key).toBe(`collections/${mockRecordId}`) + }) + + describe('when the request body is missing', () => { + test('returns a status code 400', async () => { + const event = { + body: undefined, + pathParameters: { + conceptType: 'collections' + } + } + + const response = await createStagedConcept(event) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const event = { + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'invalid-type' + } + } + + const response = await createStagedConcept(event) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when saving to s3 throws an error', () => { + test('returns a status code 404', async () => { + s3ClientMock.on(PutObjectCommand).rejects(new Error('S3 error')) + + const event = { + body: JSON.stringify({ mock: 'Concept Body' }), + pathParameters: { + conceptType: 'collections' + } + } + + const response = await createStagedConcept(event) + + expect(response.statusCode).toBe(404) + }) + }) +}) diff --git a/serverless/src/createStagedConcept/handler.js b/serverless/src/createStagedConcept/handler.js new file mode 100644 index 000000000..b863e1a3c --- /dev/null +++ b/serverless/src/createStagedConcept/handler.js @@ -0,0 +1,90 @@ +import { PutObjectCommand } from '@aws-sdk/client-s3' +import { v4 as uuidv4 } from 'uuid' + +import { getApplicationConfig } from '../../../sharedUtils/getConfig' +import { getS3Client } from '../utils/getS3Client' +import { getConceptsBucketName } from '../utils/getConceptsBucketName' +import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' + +let s3Client + +/** + * Create a concept in S3 + * + * The caller supplies only `conceptType`; a `recordId` (UUID) is generated + * here and used as the S3 key. `recordId` is returned so the caller can + * reference the stored record. + * + * This is a machine-to-machine endpoint. Authentication is handled entirely by + * the `stagingApiKeyAuthorizer` API Gateway authorizer (it verifies the + * `Staging-Api-Key` header); the handler itself does no auth. The local API + * runner (bin/api.mjs) does not invoke authorizers, so this route is + * unauthenticated locally, consistent with every other local route. + * @param {Object} event Details about the HTTP request that it received + */ +const createStagedConcept = async (event) => { + const { defaultResponseHeaders } = getApplicationConfig() + const conceptsBucketName = getConceptsBucketName() + + if (s3Client == null) { + s3Client = getS3Client() + } + + const { body, pathParameters } = event + const { conceptType } = pathParameters + + if (!body) { + console.error('Missing request body') + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + if (!s3ConceptTypes.includes(conceptType)) { + console.error(`Invalid conceptType "${conceptType}"`) + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + try { + // The caller does not supply an identifier; generate one for this record + const recordId = uuidv4() + + // S3 directory structure: s3BucketName/conceptType/recordId + const key = `${conceptType}/${recordId}` + + const putCommand = new PutObjectCommand({ + Bucket: conceptsBucketName, + Body: body, + Key: key + }) + + const response = await s3Client.send(putCommand) + + const { $metadata: metadata } = response + const { httpStatusCode: statusCode } = metadata + + return { + statusCode, + headers: defaultResponseHeaders, + // Return the generated id so the caller can reference the record + body: JSON.stringify({ + recordId + }) + } + } catch (error) { + console.log('createStagedConcept Error:', error) + + return { + statusCode: 404, + headers: defaultResponseHeaders + } + } +} + +export default createStagedConcept diff --git a/serverless/src/deleteStagedConcept/__tests__/handler.test.js b/serverless/src/deleteStagedConcept/__tests__/handler.test.js new file mode 100644 index 000000000..39bf17eb9 --- /dev/null +++ b/serverless/src/deleteStagedConcept/__tests__/handler.test.js @@ -0,0 +1,105 @@ +import { mockClient } from 'aws-sdk-client-mock' +import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3' + +import deleteStagedConcept from '../handler' + +const s3ClientMock = mockClient(S3Client) + +beforeEach(() => { + vi.clearAllMocks() + s3ClientMock.reset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('deleteStagedConcept', () => { + test('deletes the concept from s3', async () => { + s3ClientMock.on(DeleteObjectCommand).resolves({ + $metadata: { + httpStatusCode: 204, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + } + }) + + const event = { + pathParameters: { + conceptType: 'collections', + recordId: 'mock-uuid' + } + } + + const response = await deleteStagedConcept(event) + + expect(response.statusCode).toBe(204) + + const deleteCalls = s3ClientMock.commandCalls(DeleteObjectCommand) + expect(deleteCalls).toHaveLength(1) + expect(deleteCalls[0].args[0].input.Key).toBe('collections/mock-uuid') + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const event = { + pathParameters: { + conceptType: 'invalid-type', + recordId: 'mock-uuid' + } + } + + const response = await deleteStagedConcept(event) + + expect(response.statusCode).toBe(400) + expect(s3ClientMock.commandCalls(DeleteObjectCommand)).toHaveLength(0) + }) + }) + + describe('when deleting a concept that does not exist in s3', () => { + test('is treated as a successful, idempotent delete (no preflight check)', async () => { + // DeleteObject does not error on a missing key - this is the whole + // point of the idempotent-delete approach, so there's no + // HeadObjectCommand mock/check possible anymore + s3ClientMock.on(DeleteObjectCommand).resolves({ + $metadata: { + httpStatusCode: 204, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + } + }) + + const event = { + pathParameters: { + conceptType: 'collections', + recordId: 'does-not-exist' + } + } + + const response = await deleteStagedConcept(event) + + expect(response.statusCode).toBe(204) + }) + }) + + describe('when deleting the object in s3 throws an error', () => { + test('returns a status code 404', async () => { + s3ClientMock.on(DeleteObjectCommand).rejects(new Error('S3 error')) + + const event = { + pathParameters: { + conceptType: 'collections', + recordId: 'mock-uuid' + } + } + + const response = await deleteStagedConcept(event) + + expect(response.statusCode).toBe(404) + }) + }) +}) diff --git a/serverless/src/deleteStagedConcept/handler.js b/serverless/src/deleteStagedConcept/handler.js new file mode 100644 index 000000000..08480ad8a --- /dev/null +++ b/serverless/src/deleteStagedConcept/handler.js @@ -0,0 +1,67 @@ +import { DeleteObjectCommand } from '@aws-sdk/client-s3' + +import { getApplicationConfig } from '../../../sharedUtils/getConfig' +import { getS3Client } from '../utils/getS3Client' +import { getConceptsBucketName } from '../utils/getConceptsBucketName' +import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' + +let s3Client + +/** + * Delete a staged concept from S3 + * + * Staged concepts are opaque promotion artifacts keyed by a generated + * `recordId`; there is no provider/native identity to authorize against, so + * this route only requires an authenticated MMT user (the EDL authorizer). + * @param {Object} event Details about the HTTP request that it received + */ +const deleteStagedConcept = async (event) => { + const { defaultResponseHeaders } = getApplicationConfig() + + if (s3Client == null) { + s3Client = getS3Client() + } + + const { pathParameters } = event + const { conceptType, recordId } = pathParameters || {} + + if (!s3ConceptTypes.includes(conceptType)) { + console.error(`Invalid conceptType "${conceptType}"`) + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + try { + // S3 directory structure: s3BucketName/conceptType/recordId + const key = `${conceptType}/${recordId}` + const conceptsBucketName = getConceptsBucketName() + + const deleteCommand = new DeleteObjectCommand({ + Bucket: conceptsBucketName, + Key: key + }) + + const response = await s3Client.send(deleteCommand) + + const { $metadata: metadata } = response + + const { httpStatusCode: statusCode } = metadata + + return { + statusCode, + headers: defaultResponseHeaders + } + } catch (error) { + console.log('deleteStagedConcept Error:', error) + + return { + statusCode: 404, + headers: defaultResponseHeaders + } + } +} + +export default deleteStagedConcept diff --git a/serverless/src/getStagedConcept/__tests__/handler.test.js b/serverless/src/getStagedConcept/__tests__/handler.test.js new file mode 100644 index 000000000..3b3ae177d --- /dev/null +++ b/serverless/src/getStagedConcept/__tests__/handler.test.js @@ -0,0 +1,86 @@ +import { mockClient } from 'aws-sdk-client-mock' +import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3' + +import getStagedConcept from '../handler' + +const s3ClientMock = mockClient(S3Client) + +beforeEach(() => { + vi.clearAllMocks() + s3ClientMock.reset() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('getStagedConcept', () => { + test('retrieves the concept from s3', async () => { + const mockConcept = { mock: 'Concept Body' } + + s3ClientMock.on(GetObjectCommand).resolves({ + $metadata: { + httpStatusCode: 200, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + attempts: 1, + totalRetryDelay: 0 + }, + Body: { + transformToString: vi.fn().mockResolvedValue(JSON.stringify(mockConcept)) + } + }) + + const event = { + pathParameters: { + conceptType: 'collections', + recordId: 'mock-uuid' + } + } + + const response = await getStagedConcept(event) + + expect(response.statusCode).toBe(200) + + expect(JSON.parse(response.body)).toEqual({ + concept: mockConcept, + conceptType: 'collections', + recordId: 'mock-uuid' + }) + + const getCalls = s3ClientMock.commandCalls(GetObjectCommand) + expect(getCalls).toHaveLength(1) + expect(getCalls[0].args[0].input.Key).toBe('collections/mock-uuid') + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const event = { + pathParameters: { + conceptType: 'invalid-type', + recordId: 'mock-uuid' + } + } + + const response = await getStagedConcept(event) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when the object does not exist in s3', () => { + test('returns a status code 404', async () => { + s3ClientMock.on(GetObjectCommand).rejects(new Error('NoSuchKey')) + + const event = { + pathParameters: { + conceptType: 'collections', + recordId: 'mock-uuid' + } + } + + const response = await getStagedConcept(event) + + expect(response.statusCode).toBe(404) + }) + }) +}) diff --git a/serverless/src/getStagedConcept/handler.js b/serverless/src/getStagedConcept/handler.js new file mode 100644 index 000000000..587f1e37b --- /dev/null +++ b/serverless/src/getStagedConcept/handler.js @@ -0,0 +1,79 @@ +import { GetObjectCommand } from '@aws-sdk/client-s3' + +import { getApplicationConfig } from '../../../sharedUtils/getConfig' +import { getS3Client } from '../utils/getS3Client' +import { getConceptsBucketName } from '../utils/getConceptsBucketName' +import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' + +let s3Client + +/** + * Retrieve a staged concept from S3 + * + * Staged concepts are opaque promotion artifacts keyed by a generated + * `recordId` (see `createStagedConcept`); there is no provider/native + * identity to authorize against, so this route only requires an authenticated + * MMT user (the EDL authorizer). + * @param {Object} event Details about the HTTP request that it received + */ +const getStagedConcept = async (event) => { + const { defaultResponseHeaders } = getApplicationConfig() + + if (s3Client == null) { + s3Client = getS3Client() + } + + const { pathParameters } = event + const { conceptType, recordId } = pathParameters || {} + + if (!s3ConceptTypes.includes(conceptType)) { + console.error(`Invalid conceptType "${conceptType}"`) + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + try { + // S3 directory structure: s3BucketName/conceptType/recordId + const key = `${conceptType}/${recordId}` + + // Retrieve the file from S3 + const conceptsBucketName = getConceptsBucketName() + const getCommand = new GetObjectCommand({ + Bucket: conceptsBucketName, + Key: key + }) + + const response = await s3Client.send(getCommand) + + const { $metadata: metadata } = response + + const { httpStatusCode: statusCode } = metadata + + // Transform the body into a string to return + const { Body: responseBody } = response + + const body = { + concept: JSON.parse(await responseBody.transformToString()), + conceptType, + recordId + } + + return { + body: JSON.stringify(body), + statusCode, + headers: defaultResponseHeaders + } + } catch (error) { + console.log('getStagedConcept Error:', error) + + return { + statusCode: 404, + headers: defaultResponseHeaders + } + } +} + +export default getStagedConcept diff --git a/serverless/src/stageConceptForProduction/__tests__/handler.test.js b/serverless/src/stageConceptForProduction/__tests__/handler.test.js new file mode 100644 index 000000000..8da8b83ab --- /dev/null +++ b/serverless/src/stageConceptForProduction/__tests__/handler.test.js @@ -0,0 +1,160 @@ +import stageConceptForProduction from '../handler' + +const validEvent = { + body: JSON.stringify({ + ShortName: 'Test', + Version: '1' + }), + headers: { + Authorization: 'Bearer ABC-1' + }, + pathParameters: { + conceptType: 'collections', + providerId: 'MMT_1' + } +} + +const mockProductionResponse = (overrides = {}) => ({ + ok: true, + status: 200, + json: () => Promise.resolve({ + recordId: 'prod-record-1' + }), + ...overrides +}) + +beforeEach(() => { + vi.clearAllMocks() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + + process.env.STAGING_TARGET_API_HOST = 'https://prod.example.com/prod' + process.env.STAGING_TARGET_MMT_HOST = 'https://mmt.example.com' + process.env.STAGING_TARGET_API_KEY = 'prod-staging-key' +}) + +describe('stageConceptForProduction', () => { + test('forwards the metadata to the staging target and returns a staged concept link', async () => { + global.fetch = vi.fn(() => Promise.resolve(mockProductionResponse())) + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toEqual({ + stagedConceptLink: 'https://mmt.example.com/collections/staged/prod-record-1' + }) + + expect(global.fetch).toHaveBeenCalledWith( + 'https://prod.example.com/prod/staged/collections', + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ 'Staging-Api-Key': 'prod-staging-key' }), + body: validEvent.body + }) + ) + }) + + describe('when the conceptType is invalid', () => { + test('returns a status code 400', async () => { + const response = await stageConceptForProduction({ + ...validEvent, + pathParameters: { + ...validEvent.pathParameters, + conceptType: 'invalid-type' + } + }) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when the request body is missing', () => { + test('returns a status code 400', async () => { + const response = await stageConceptForProduction({ + ...validEvent, + body: undefined + }) + + expect(response.statusCode).toBe(400) + }) + }) + + describe('when the user is not authorized for the provider', () => { + test('returns a status code 403', async () => { + const response = await stageConceptForProduction({ + ...validEvent, + pathParameters: { + ...validEvent.pathParameters, + providerId: 'MMT_3' + } + }) + + expect(response.statusCode).toBe(403) + }) + }) + + describe('when fetching providers throws an error', () => { + test('returns a status code 500', async () => { + const response = await stageConceptForProduction({ + ...validEvent, + headers: { Authorization: 'Bearer invalid_token' } + }) + + expect(response.statusCode).toBe(500) + }) + }) + + describe('when no staging target is configured', () => { + test('returns a status code 500 when STAGING_TARGET_API_HOST is missing', async () => { + delete process.env.STAGING_TARGET_API_HOST + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(500) + }) + + test('returns a status code 500 when STAGING_TARGET_MMT_HOST is missing', async () => { + delete process.env.STAGING_TARGET_MMT_HOST + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(500) + expect(global.fetch).not.toHaveBeenCalled() + }) + + test('returns a status code 500 when STAGING_TARGET_API_KEY is missing', async () => { + delete process.env.STAGING_TARGET_API_KEY + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(500) + }) + }) + + describe('when the staging target rejects the request', () => { + test('returns a status code 502', async () => { + global.fetch = vi.fn(() => Promise.resolve(mockProductionResponse({ + ok: false, + status: 401 + }))) + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(502) + expect(JSON.parse(response.body)).toEqual({ + error: 'Staging target rejected the request with status 401' + }) + }) + }) + + describe('when the forward call throws', () => { + test('returns a status code 502', async () => { + global.fetch = vi.fn(() => Promise.reject(new Error('Network down'))) + + const response = await stageConceptForProduction(validEvent) + + expect(response.statusCode).toBe(502) + expect(JSON.parse(response.body)).toEqual({ error: 'Error: Network down' }) + }) + }) +}) diff --git a/serverless/src/stageConceptForProduction/handler.js b/serverless/src/stageConceptForProduction/handler.js new file mode 100644 index 000000000..68ba70bbd --- /dev/null +++ b/serverless/src/stageConceptForProduction/handler.js @@ -0,0 +1,128 @@ +import { getApplicationConfig } from '../../../sharedUtils/getConfig' +import { s3ConceptTypes } from '../../../sharedConstants/s3ConceptTypes' +import fetchProviders from '../utils/fetchProviders' + +/** + * Forwards a collection's metadata from this MMT environment to another MMT + * environment's API Gateway so it can be staged there. The typical use is + * UAT → Production, but the target is whatever `STAGING_TARGET_*` points at + * (SIT → UAT, a same-environment loopback for local testing, etc.). + * + * This Lambda runs behind the EDL authorizer (a real browser user). It verifies + * the user may act for the given provider, then calls the staging target's + * `createStagedConcept` endpoint using the target's staging API key held + * in an environment variable (so the key never reaches the browser). The target + * stores the metadata under a generated `recordId` and returns it; this Lambda + * turns that into a deep link the user can follow to continue the workflow + * there. + * + * `providerId` is used only for the per-user permission check; it is not part + * of the forwarded request (staged concepts have no provider identity). + * @param {Object} event Details about the HTTP request that it received + */ +const stageConceptForProduction = async (event) => { + const { defaultResponseHeaders } = getApplicationConfig() + + const { body, pathParameters } = event + const { conceptType, providerId } = pathParameters || {} + + if (!s3ConceptTypes.includes(conceptType)) { + console.error(`Invalid conceptType "${conceptType}"`) + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + if (!body) { + console.error('Missing request body') + + return { + statusCode: 400, + headers: defaultResponseHeaders + } + } + + try { + const allowedProviderIds = await fetchProviders(event) + + if (!allowedProviderIds.includes(providerId)) { + console.error(`Missing permissions for provider "${providerId}"`) + + return { + statusCode: 403, + headers: defaultResponseHeaders + } + } + } catch (error) { + console.log('Error fetching providers:', error) + + return { + statusCode: 500, + headers: defaultResponseHeaders + } + } + + const { + STAGING_TARGET_API_HOST: stagingTargetApiHost, + STAGING_TARGET_MMT_HOST: stagingTargetMmtHost, + STAGING_TARGET_API_KEY: stagingTargetApiKey + } = process.env + + if (!stagingTargetApiHost || !stagingTargetMmtHost || !stagingTargetApiKey) { + console.error('Staging target is not fully configured for this environment') + + return { + statusCode: 500, + headers: defaultResponseHeaders + } + } + + const stagingTargetUrl = `${stagingTargetApiHost}/staged/${conceptType}` + + try { + const response = await fetch(stagingTargetUrl, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Staging-Api-Key': stagingTargetApiKey + }, + body + }) + + if (!response.ok) { + console.error(`Staging target responded with status ${response.status} staging a "${conceptType}" concept`) + + return { + statusCode: 502, + headers: defaultResponseHeaders, + body: JSON.stringify({ + error: `Staging target rejected the request with status ${response.status}` + }) + } + } + + const { recordId } = await response.json() + + return { + statusCode: 200, + headers: defaultResponseHeaders, + body: JSON.stringify({ + stagedConceptLink: `${stagingTargetMmtHost}/${conceptType}/staged/${recordId}` + }) + } + } catch (error) { + console.error(`Error staging concept for production: ${error.toString()}`) + + return { + statusCode: 502, + headers: defaultResponseHeaders, + body: JSON.stringify({ + error: error.toString() + }) + } + } +} + +export default stageConceptForProduction diff --git a/serverless/src/stagingApiKeyAuthorizer/__tests__/handler.test.js b/serverless/src/stagingApiKeyAuthorizer/__tests__/handler.test.js new file mode 100644 index 000000000..3af05e519 --- /dev/null +++ b/serverless/src/stagingApiKeyAuthorizer/__tests__/handler.test.js @@ -0,0 +1,113 @@ +import stagingApiKeyAuthorizer from '../handler' + +const methodArn = 'arn:aws:execute-api:us-east-1:123456789012:api-id/stage/PUT/staged/collections' + +describe('stagingApiKeyAuthorizer', () => { + const OLD_ENV = process.env + + beforeEach(() => { + process.env = { ...OLD_ENV } + delete process.env.IS_OFFLINE + + vi.spyOn(console, 'error').mockImplementation(() => {}) + + process.env.STAGING_API_KEY = 'test-staging-key' + }) + + afterEach(() => { + process.env = OLD_ENV + }) + + describe('when running offline', () => { + test('returns an Allow policy without checking the key', async () => { + process.env.IS_OFFLINE = 'true' + delete process.env.STAGING_API_KEY + + const response = await stagingApiKeyAuthorizer({ + headers: {}, + methodArn + }) + + expect(response).toEqual({ + principalId: 'offline', + policyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: methodArn + } + ] + } + }) + }) + }) + + describe('when the Staging-Api-Key header matches', () => { + test('returns an Allow policy', async () => { + const response = await stagingApiKeyAuthorizer({ + headers: { 'Staging-Api-Key': 'test-staging-key' }, + methodArn + }) + + expect(response).toEqual({ + principalId: 'staging-api-client', + policyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'execute-api:Invoke', + Effect: 'Allow', + Resource: methodArn + } + ] + } + }) + }) + + test('is accepted regardless of header casing', async () => { + const response = await stagingApiKeyAuthorizer({ + headers: { 'staging-api-key': 'test-staging-key' }, + methodArn + }) + + expect(response.principalId).toBe('staging-api-client') + }) + }) + + describe('when the Staging-Api-Key header is missing', () => { + test('throws Unauthorized', async () => { + await expect( + stagingApiKeyAuthorizer({ + headers: {}, + methodArn + }) + ).rejects.toThrow('Unauthorized') + }) + }) + + describe('when the Staging-Api-Key header does not match', () => { + test('throws Unauthorized', async () => { + await expect( + stagingApiKeyAuthorizer({ + headers: { 'Staging-Api-Key': 'wrong-key' }, + methodArn + }) + ).rejects.toThrow('Unauthorized') + }) + }) + + describe('when STAGING_API_KEY is not configured in the environment', () => { + test('throws Unauthorized even when the header matches an empty value', async () => { + delete process.env.STAGING_API_KEY + + await expect( + stagingApiKeyAuthorizer({ + headers: {}, + methodArn + }) + ).rejects.toThrow('Unauthorized') + }) + }) +}) diff --git a/serverless/src/stagingApiKeyAuthorizer/handler.js b/serverless/src/stagingApiKeyAuthorizer/handler.js new file mode 100644 index 000000000..798266469 --- /dev/null +++ b/serverless/src/stagingApiKeyAuthorizer/handler.js @@ -0,0 +1,38 @@ +import { generatePolicy } from '../utils/authorizer/generatePolicy' +import { downcaseKeys } from '../utils/downcaseKeys' + +/** + * Custom API Gateway authorizer for the machine-to-machine `createStagedConcept` + * route (`PUT /staged/{conceptType}`). It authenticates the caller solely by a shared + * secret sent in the `Staging-Api-Key` header (compared against + * `process.env.STAGING_API_KEY`). + * + * That route is called server-to-server by another environment's + * `stageConceptForProduction` forwarding Lambda, not by a browser user with an EDL token, + * so it uses this authorizer instead of the EDL one. + * @param {Object} event Details about the HTTP request that it received + */ +const stagingApiKeyAuthorizer = async (event) => { + const { headers = {}, methodArn } = event + + // Allow local development invocations to bypass auth. The local API runner + // (bin/api.mjs) never invokes authorizers, so this only matters if the + // authorizer handler is exercised directly. + if (process.env.IS_OFFLINE) { + return generatePolicy('offline', 'Allow', methodArn) + } + + const { 'staging-api-key': stagingApiKey } = downcaseKeys(headers) + const expectedApiKey = process.env.STAGING_API_KEY + + // Fail closed when the expected key is not configured in the environment. + if (!expectedApiKey || stagingApiKey !== expectedApiKey) { + console.error('Missing or invalid Staging-Api-Key header') + + throw new Error('Unauthorized') + } + + return generatePolicy('staging-api-client', 'Allow', methodArn) +} + +export default stagingApiKeyAuthorizer diff --git a/serverless/src/utils/__tests__/getConceptsBucketName.test.js b/serverless/src/utils/__tests__/getConceptsBucketName.test.js new file mode 100644 index 000000000..2333d6493 --- /dev/null +++ b/serverless/src/utils/__tests__/getConceptsBucketName.test.js @@ -0,0 +1,10 @@ +import { getConceptsBucketName } from '../getConceptsBucketName' + +describe('getConceptsBucketName', () => { + test('returns the default when offline', () => { + process.env.IS_OFFLINE = true + const bucketName = getConceptsBucketName() + + expect(bucketName).toBe('mmt-staging-concepts-bucket-local') + }) +}) diff --git a/serverless/src/utils/getConceptsBucketName.js b/serverless/src/utils/getConceptsBucketName.js new file mode 100644 index 000000000..6edbceab5 --- /dev/null +++ b/serverless/src/utils/getConceptsBucketName.js @@ -0,0 +1,10 @@ +/** + * Returns the concepts bucket name + */ +export const getConceptsBucketName = () => { + if (process.env.IS_OFFLINE) { + return 'mmt-staging-concepts-bucket-local' + } + + return process.env.STAGING_CONCEPTS_BUCKET_NAME +} diff --git a/setup/startS3.js b/setup/startS3.js index 92bc43be3..64d5de7ef 100644 --- a/setup/startS3.js +++ b/setup/startS3.js @@ -1,8 +1,11 @@ const S3rver = require('s3rver') const { S3Client, CreateBucketCommand, HeadBucketCommand } = require('@aws-sdk/client-s3') -// Allow overriding this value but, this script is only need to be run in dev -const bucketName = process.env.COLLECTION_TEMPLATES_BUCKET_NAME || 'mmt-template-bucket-local' +// Allow overriding these values but, this script is only need to be run in dev +const bucketNames = [ + process.env.COLLECTION_TEMPLATES_BUCKET_NAME || 'mmt-template-bucket-local', + process.env.STAGING_CONCEPTS_BUCKET_NAME || 'mmt-staging-concepts-bucket-local' +] const port = 4569 const startS3 = async () => { @@ -29,18 +32,22 @@ const startS3 = async () => { region: 'us-east-1' }) - try { - await s3Client.send(new HeadBucketCommand({ Bucket: bucketName })) - console.log(`Bucket "${bucketName}" already exists.`) - } catch (error) { - if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { - console.log(`Creating bucket "${bucketName}"...`) - await s3Client.send(new CreateBucketCommand({ Bucket: bucketName })) - console.log(`Bucket "${bucketName}" created.`) - } else { - console.error('Error checking/creating bucket:', error) + const ensureBucketExists = async (bucketName) => { + try { + await s3Client.send(new HeadBucketCommand({ Bucket: bucketName })) + console.log(`Bucket "${bucketName}" already exists.`) + } catch (error) { + if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) { + console.log(`Creating bucket "${bucketName}"...`) + await s3Client.send(new CreateBucketCommand({ Bucket: bucketName })) + console.log(`Bucket "${bucketName}" created.`) + } else { + console.error(`Error checking/creating bucket "${bucketName}":`, error) + } } } + + await Promise.all(bucketNames.map(ensureBucketExists)) } startS3().catch((error) => { diff --git a/sharedConstants/s3ConceptTypes.js b/sharedConstants/s3ConceptTypes.js new file mode 100644 index 000000000..739241c8c --- /dev/null +++ b/sharedConstants/s3ConceptTypes.js @@ -0,0 +1,3 @@ +export const s3ConceptTypes = [ + 'collections' +]