Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
77 changes: 77 additions & 0 deletions docs/openapi/llmo-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions src/controllers/llmo/llmo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>} 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,
Expand Down
1 change: 1 addition & 0 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/routes/required-capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions test/controllers/llmo/llmo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
1 change: 1 addition & 0 deletions test/routes/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading