diff --git a/docs/openapi/api.yaml b/docs/openapi/api.yaml index b4bf492867..c2e9549c1d 100644 --- a/docs/openapi/api.yaml +++ b/docs/openapi/api.yaml @@ -623,6 +623,8 @@ paths: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-config' /sites/{siteId}/llmo/edge-optimize-config/stage: $ref: './llmo-api.yaml#/site-llmo-edge-optimize-config-stage' + /sites/{siteId}/llmo/edge-optimize-bootstrap-url: + $ref: './llmo-api.yaml#/site-llmo-edge-optimize-bootstrap-url' /sites/{siteId}/llmo/edge-optimize-status: $ref: './llmo-api.yaml#/llmo-edge-optimize-status' /sites/{siteId}/llmo/probes/edge-optimize: diff --git a/docs/openapi/llmo-api.yaml b/docs/openapi/llmo-api.yaml index a8e07d498f..877bd3a195 100644 --- a/docs/openapi/llmo-api.yaml +++ b/docs/openapi/llmo-api.yaml @@ -2332,6 +2332,83 @@ llmo-edge-optimize-status: security: - api_key: [ ] +site-llmo-edge-optimize-bootstrap-url: + post: + tags: + - llmo + summary: Generate a CloudFormation quick-create URL for the Edge Optimize connector role + description: | + Returns a one-click AWS CloudFormation quick-create URL (with a server-side + presigned template URL) that the customer uses to create the cross-account + connector role in their own AWS account. The template bucket stays private — + presigning is done with the service execution role — so no public S3 endpoint + is required and the customer needs no S3 access of their own. + operationId: getEdgeOptimizeBootstrapUrl + parameters: + - $ref: './parameters.yaml#/siteId' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - accountId + properties: + accountId: + type: string + description: The 12-digit AWS account ID hosting the customer's CloudFront distribution + pattern: '^[0-9]{12}$' + example: '682033462621' + responses: + '200': + description: Bootstrap URL generated successfully + content: + application/json: + schema: + type: object + required: + - externalId + - roleArn + - quickCreateUrl + properties: + externalId: + type: string + description: Per-session external ID baked into the role's trust policy + roleName: + type: string + roleArn: + type: string + trustedPrincipalArn: + type: string + stackName: + type: string + quickCreateUrl: + type: string + description: One-click CloudFormation quick-create URL (presigned template) + presignTtlSeconds: + type: integer + example: + externalId: '7ff9518a-cf59-40b4-aa53-68a3cb2e24a5' + roleName: AdobeLLMOptimizerCloudFrontConnectorRole + roleArn: 'arn:aws:iam::682033462621:role/AdobeLLMOptimizerCloudFrontConnectorRole' + trustedPrincipalArn: 'arn:aws:iam::120569600543:root' + stackName: adobe-edgeoptimize-connector-role + quickCreateUrl: 'https://us-east-1.console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/quickcreate?templateURL=...' + presignTtlSeconds: 900 + '400': + $ref: './responses.yaml#/400' + '401': + $ref: './responses.yaml#/401' + '403': + $ref: './responses.yaml#/403' + '404': + $ref: './responses.yaml#/404' + '500': + $ref: './responses.yaml#/500' + security: + - api_key: [ ] + llmo-probe-edge-optimize: get: tags: diff --git a/src/controllers/llmo/llmo.js b/src/controllers/llmo/llmo.js index 7e2846ec4e..1d5136289f 100644 --- a/src/controllers/llmo/llmo.js +++ b/src/controllers/llmo/llmo.js @@ -2064,7 +2064,104 @@ function LlmoController(ctx) { return ok(result); }; + /** + * POST /sites/{siteId}/llmo/edge-optimize-bootstrap-url + * Builds a one-click CloudFormation quick-create URL (with a server-side + * presigned template URL) the customer uses to create the cross-account + * connector role in their own AWS account. Presigning runs with the service + * execution role, so the template bucket stays private (no public endpoint) + * and the customer needs no S3 access. + * @param {object} context - Request context + * @returns {Promise} Bootstrap details + CloudFormation quick-create URL + */ + const getEdgeOptimizeBootstrapUrl = async (context) => { + const { + log, dataAccess, env, s3, + } = context; + const { siteId } = context.params; + const { Site } = dataAccess; + const accountId = String(context.data?.accountId || '').replace(/\D/g, ''); + + if (accountId.length !== 12) { + return badRequest('accountId must be a 12-digit AWS account ID'); + } + + try { + const site = await Site.findById(siteId); + if (!site) { + return notFound('Site not found'); + } + if (!await accessControlUtil.hasAccess(site)) { + return forbidden('User does not have access to this site'); + } + if (!accessControlUtil.isLLMOAdministrator()) { + return forbidden('Only LLMO administrators can generate the edge optimize bootstrap URL'); + } + + // TEMPORARY (testing only): hardcoded fallback so the dev/ci deploy returns a URL + // before EDGE_OPTIMIZE_TEMPLATE_BUCKET is wired into Vault/secrets. This bucket lives + // in the dev account (682033462621) where the service deploys and signs, so the dev + // role reads it same-account; the stage customer fetches it via the presigned URL. + // TODO: REMOVE this default before merge/prod — the value must come from env config. + const bucket = env.EDGE_OPTIMIZE_TEMPLATE_BUCKET || 'llmo-edgeoptimize-cf-template'; + if (!hasText(bucket) || !s3?.s3Client) { + return badRequest('Edge optimize template hosting is not configured for this environment'); + } + + const key = env.EDGE_OPTIMIZE_TEMPLATE_KEY || 'customer-bootstrap-role.yaml'; + const region = 'us-east-1'; + const roleName = env.EDGE_OPTIMIZE_ROLE_NAME || 'AdobeLLMOptimizerCloudFrontConnectorRole'; + const stackName = env.EDGE_OPTIMIZE_STACK_NAME || 'adobe-edgeoptimize-connector-role'; + // Short-lived presign: the customer opens the link immediately, so a tight TTL + // shrinks the exposure window if the URL leaks (it only grants GetObject on this + // one template object until expiry — see security notes). Override via env. + const presignTtlSeconds = Number(env.EDGE_OPTIMIZE_PRESIGN_TTL || 900); + const externalId = crypto.randomUUID(); + const roleArn = `arn:aws:iam::${accountId}:role/${roleName}`; + // TEMPORARY (testing only): default the trust to the dev signer account so the + // cross-account test works (dev signs, stage is the customer where the role is made). + // TODO: REMOVE before merge/prod — set EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN via env. + const trustedPrincipalArn = env.EDGE_OPTIMIZE_TRUSTED_PRINCIPAL_ARN + || 'arn:aws:iam::682033462621:root'; + + // Presign the (private) template so the customer's CloudFormation can read it + // cross-account via the signature — no public bucket, no customer S3 access. + const templateUrl = await s3.getSignedUrl( + s3.s3Client, + new s3.GetObjectCommand({ Bucket: bucket, Key: key }), + { expiresIn: presignTtlSeconds }, + ); + + const params = { + TrustedPrincipalArn: trustedPrincipalArn, + ExternalId: externalId, + RoleName: roleName, + }; + const qs = new URLSearchParams(); + qs.set('templateURL', templateUrl); + qs.set('stackName', stackName); + Object.entries(params).forEach(([k, v]) => qs.set(`param_${k}`, v)); + const quickCreateUrl = `https://${region}.console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/quickcreate?${qs.toString()}`; + + log.info(`[edge-optimize-bootstrap-url] Generated bootstrap URL for site ${siteId}, account ${accountId}`); + + return ok({ + externalId, + roleName, + roleArn, + trustedPrincipalArn, + stackName, + quickCreateUrl, + presignTtlSeconds, + }); + } catch (error) { + log.error(`Failed to generate edge optimize bootstrap URL for site ${siteId}:`, error); + return badRequest(cleanupHeaderValue(error.message)); + } + }; + return { + getEdgeOptimizeBootstrapUrl, getLlmoSheetData, queryLlmoSheetData, getLlmoGlobalSheetData, diff --git a/src/routes/index.js b/src/routes/index.js index 2d724ae815..60384afb9a 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -480,6 +480,7 @@ export default function getRouteHandlers( 'POST /sites/:siteId/llmo/edge-optimize-config': llmoController.createOrUpdateEdgeConfig, 'GET /sites/:siteId/llmo/edge-optimize-config': llmoController.getEdgeConfig, 'POST /sites/:siteId/llmo/edge-optimize-config/stage': llmoController.createOrUpdateStageEdgeConfig, + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url': llmoController.getEdgeOptimizeBootstrapUrl, 'GET /sites/:siteId/llmo/strategy': llmoController.getStrategy, 'PUT /sites/:siteId/llmo/strategy': llmoController.saveStrategy, 'GET /sites/:siteId/llmo/edge-optimize-status': llmoController.checkEdgeOptimizeStatus, diff --git a/src/routes/required-capabilities.js b/src/routes/required-capabilities.js index afab742aa0..1654d99f19 100644 --- a/src/routes/required-capabilities.js +++ b/src/routes/required-capabilities.js @@ -127,6 +127,7 @@ export const INTERNAL_ROUTES = [ 'POST /sites/:siteId/llmo/offboard', 'POST /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'PUT /sites/:siteId/llmo/opportunities-reviewed', // PLG onboarding - IMS token auth, self-service flow, not S2S diff --git a/test/controllers/llmo/llmo.test.js b/test/controllers/llmo/llmo.test.js index 6b3be10f0a..f6ff316f2e 100644 --- a/test/controllers/llmo/llmo.test.js +++ b/test/controllers/llmo/llmo.test.js @@ -7554,6 +7554,74 @@ describe('LlmoController', () => { }); }); + describe('getEdgeOptimizeBootstrapUrl', () => { + let bootstrapContext; + let getSignedUrlStub; + + beforeEach(() => { + getSignedUrlStub = sinon.stub().resolves('https://llmo-edgeoptimize-cf-template-stage.s3.us-east-1.amazonaws.com/customer-bootstrap-role.yaml?X-Amz-Signature=abc'); + bootstrapContext = { + ...mockContext, + params: { siteId: TEST_SITE_ID }, + data: { accountId: '682033462621' }, + env: { EDGE_OPTIMIZE_TEMPLATE_BUCKET: 'llmo-edgeoptimize-cf-template-stage' }, + s3: { + s3Client: {}, + getSignedUrl: getSignedUrlStub, + GetObjectCommand: class GetObjectCommand {}, + }, + }; + }); + + it('returns a quick-create URL with a presigned template for a valid account', async () => { + const result = await controller.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(200); + const body = await result.json(); + expect(body.quickCreateUrl).to.include('stacks/quickcreate'); + expect(body.quickCreateUrl).to.include('templateURL='); + expect(body.quickCreateUrl).to.include('param_RoleName=AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(body.roleArn).to.equal('arn:aws:iam::682033462621:role/AdobeLLMOptimizerCloudFrontConnectorRole'); + expect(body.externalId).to.be.a('string'); + expect(getSignedUrlStub.calledOnce).to.equal(true); + }); + + it('returns 400 for an invalid account id', async () => { + const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, data: { accountId: '123' } }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('12-digit'); + }); + + it('returns 400 when template hosting is not configured (no S3 client)', async () => { + // While the TEMPORARY hardcoded bucket default is in place the bucket is always + // set, so the "not configured" guard is exercised via the missing S3 client. + // TODO: restore the `env: {}` (empty-bucket) variant once the temp default is removed. + const result = await controller.getEdgeOptimizeBootstrapUrl({ ...bootstrapContext, s3: {} }); + + expect(result.status).to.equal(400); + const body = await result.json(); + expect(body.message).to.include('not configured'); + }); + + it('returns 404 when the site is not found', async () => { + mockDataAccess.Site.findById.resolves(null); + + const result = await controller.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(404); + }); + + it('returns 403 when the user lacks access to the site', async () => { + const deniedController = controllerWithAccessDenied(mockContext); + + const result = await deniedController.getEdgeOptimizeBootstrapUrl(bootstrapContext); + + expect(result.status).to.equal(403); + }); + }); + describe('getStrategy', () => { const mockStrategyData = { opportunities: [ diff --git a/test/routes/index.test.js b/test/routes/index.test.js index 468dfaf687..95867b176d 100755 --- a/test/routes/index.test.js +++ b/test/routes/index.test.js @@ -1073,6 +1073,7 @@ describe('getRouteHandlers', () => { 'POST /sites/:siteId/llmo/edge-optimize-config', 'GET /sites/:siteId/llmo/edge-optimize-config', 'POST /sites/:siteId/llmo/edge-optimize-config/stage', + 'POST /sites/:siteId/llmo/edge-optimize-bootstrap-url', 'GET /sites/:siteId/llmo/edge-optimize-status', 'GET /sites/:siteId/llmo/probes/edge-optimize', 'GET /sites/:siteId/llmo/strategy',