mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 10:23:11 +00:00
* ci: add protected staging deployment lane * fix: constrain staging publisher permissions * fix: handle first-push governance baseline --------- Co-authored-by: Codex Review Integration <codex-review@local.invalid>
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
import { Duration, RemovalPolicy, Stack, StackProps, CfnOutput } from "aws-cdk-lib";
|
|
import { Construct } from "constructs";
|
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
|
|
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
|
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
import * as acm from "aws-cdk-lib/aws-certificatemanager";
|
|
import * as route53 from "aws-cdk-lib/aws-route53";
|
|
import * as targets from "aws-cdk-lib/aws-route53-targets";
|
|
|
|
export interface FrontendStackProps extends StackProps {
|
|
/** Environment label, e.g. "dev". Used in names/tags. */
|
|
readonly envName: string;
|
|
/** GitHub repo in owner/name form, for OIDC trust scoping. */
|
|
readonly githubRepo: string;
|
|
/** Git branch whose pushes may deploy (OIDC sub is scoped to this ref). */
|
|
readonly deployBranch: string;
|
|
/**
|
|
* GitHub Actions environment name (e.g. "staging"). When set, the OIDC
|
|
* trust uses the EXACT environment subject
|
|
* `repo:<owner/name>:environment:<env>` (StringEquals) instead of the
|
|
* deploy-branch ref match below. Unset = dev-style branch-ref trust.
|
|
*/
|
|
readonly githubEnvironment?: string;
|
|
/**
|
|
* Custom domain(s) for the distribution, e.g. ["dev.seahaven.com"].
|
|
* Empty = serve on the default *.cloudfront.net domain.
|
|
*/
|
|
readonly domainNames: string[];
|
|
/**
|
|
* ARN of an ACM certificate (us-east-1, SAME account as this stack) covering
|
|
* `domainNames`. Required when `domainNames` is non-empty. CloudFront cannot
|
|
* use a certificate from another account, so for Option B the cert must live
|
|
* in whichever account this stack deploys to.
|
|
*/
|
|
readonly certificateArn: string;
|
|
/**
|
|
* Route 53 hosted zone (in THIS account) to create the custom-domain alias
|
|
* record in. Empty = don't manage DNS (add the record manually). When set,
|
|
* hostedZoneName must also be provided.
|
|
*/
|
|
readonly hostedZoneId: string;
|
|
/** Name of the hosted zone above, e.g. "dev.seahaven.com". */
|
|
readonly hostedZoneName: string;
|
|
}
|
|
|
|
/**
|
|
* Static SPA hosting for the Sea Haven SHOC frontend:
|
|
* - private S3 bucket (no public access; CloudFront reads it via OAC)
|
|
* - CloudFront distribution (HTTPS, SPA deep-link fallback)
|
|
* - a GitHub Actions OIDC deploy role
|
|
*
|
|
* Content (the built `dist/`) is NOT uploaded here. The org's reusable
|
|
* `cd-cdk.yaml` workflow runs `scripts/deploy-web.sh` after `cdk deploy` to
|
|
* build the SPA, sync it to this bucket, and invalidate CloudFront — so this
|
|
* stack only owns the infrastructure, and the deploy role carries the
|
|
* permissions those post-deploy steps need.
|
|
*/
|
|
export class FrontendStack extends Stack {
|
|
constructor(scope: Construct, id: string, props: FrontendStackProps) {
|
|
super(scope, id, props);
|
|
|
|
const {
|
|
envName,
|
|
githubRepo,
|
|
deployBranch,
|
|
githubEnvironment = "",
|
|
domainNames,
|
|
certificateArn,
|
|
hostedZoneId,
|
|
hostedZoneName,
|
|
} = props;
|
|
|
|
const hasCustomDomain = domainNames.length > 0;
|
|
if (hasCustomDomain && !certificateArn) {
|
|
throw new Error(
|
|
"certificateArn is required when domainNames is set (ACM cert must be in us-east-1, same account).",
|
|
);
|
|
}
|
|
|
|
// --- Origin bucket: private, encrypted, no public access ----------------
|
|
const bucket = new s3.Bucket(this, "SiteBucket", {
|
|
bucketName: `seahaven-shoc-frontend-${envName}`,
|
|
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
|
objectOwnership: s3.ObjectOwnership.BUCKET_OWNER_ENFORCED,
|
|
encryption: s3.BucketEncryption.S3_MANAGED,
|
|
enforceSSL: true,
|
|
versioned: true,
|
|
// dev artifacts are reproducible from the build — safe to tear down.
|
|
removalPolicy: RemovalPolicy.DESTROY,
|
|
autoDeleteObjects: true,
|
|
});
|
|
|
|
// SPA client-side routing: rewrite extensionless paths (e.g. /work-orders)
|
|
// to /index.html so deep links resolve. Done with a CloudFront Function
|
|
// rather than customErrorResponses so real asset 404s stay 404s.
|
|
const spaRewrite = new cloudfront.Function(this, "SpaRewrite", {
|
|
comment: "SPA routing: rewrite extensionless paths to /index.html",
|
|
code: cloudfront.FunctionCode.fromInline(
|
|
[
|
|
"function handler(event) {",
|
|
" var request = event.request;",
|
|
" var uri = request.uri;",
|
|
" // No file extension after the last slash -> a client-side route.",
|
|
" if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) {",
|
|
" request.uri = '/index.html';",
|
|
" }",
|
|
" return request;",
|
|
"}",
|
|
].join("\n"),
|
|
),
|
|
});
|
|
|
|
// --- CloudFront: serves the static SPA from S3 -------------------------
|
|
// The SPA calls the backend directly at its absolute HTTPS URL
|
|
// (VITE_API_URL, cross-origin), so CloudFront hosts only static content.
|
|
const distribution = new cloudfront.Distribution(this, "Distribution", {
|
|
comment: `SeaHaven SHOC frontend (${envName})`,
|
|
defaultRootObject: "index.html",
|
|
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
|
|
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
|
|
// Option B: serve on the custom domain(s) with the ACM cert. When unset,
|
|
// CloudFront uses its default *.cloudfront.net domain + certificate.
|
|
domainNames: hasCustomDomain ? domainNames : undefined,
|
|
certificate: hasCustomDomain
|
|
? acm.Certificate.fromCertificateArn(this, "Certificate", certificateArn)
|
|
: undefined,
|
|
minimumProtocolVersion: hasCustomDomain
|
|
? cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021
|
|
: undefined,
|
|
defaultBehavior: {
|
|
// withOriginAccessControl wires up OAC + the bucket policy automatically.
|
|
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
|
|
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
|
|
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
|
|
allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
|
|
compress: true,
|
|
functionAssociations: [
|
|
{
|
|
function: spaRewrite,
|
|
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
// --- GitHub Actions OIDC deploy role -----------------------------------
|
|
// The OIDC provider is a singleton account-global resource, created once
|
|
// out-of-band (see README step 2) — we only IMPORT it here so this stack's
|
|
// lifecycle (including `cdk destroy`) never deletes a resource shared by
|
|
// every role in the account.
|
|
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
|
this,
|
|
"GitHubOidcProvider",
|
|
`arn:aws:iam::${this.account}:oidc-provider/token.actions.githubusercontent.com`,
|
|
);
|
|
|
|
// Trust conditions for the OIDC principal. With a GitHub environment
|
|
// (staging): exact StringEquals match on both aud and the environment
|
|
// subject — the staging workflow declares `environment: staging`, so only
|
|
// runs in that environment can assume the role. Without one (dev): keep
|
|
// the branch-ref trust, where StringLike scopes `sub` to pushes on the
|
|
// deploy branch (reusable-workflow runs still carry the caller-based sub).
|
|
const oidcConditions = githubEnvironment
|
|
? {
|
|
StringEquals: {
|
|
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
|
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:environment:${githubEnvironment}`,
|
|
},
|
|
}
|
|
: {
|
|
StringEquals: {
|
|
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
|
},
|
|
StringLike: {
|
|
// Tightly scoped: only pushes to this repo's deploy branch. For a
|
|
// reusable-workflow run the OIDC `sub` is still caller-based, so this
|
|
// matches even though the deploy job lives in the `.github` repo.
|
|
"token.actions.githubusercontent.com:sub": `repo:${githubRepo}:ref:refs/heads/${deployBranch}`,
|
|
},
|
|
};
|
|
|
|
const deployRole = new iam.Role(this, "GithubDeployRole", {
|
|
roleName: `githubdeploy-shoc-frontend-new-${envName}`,
|
|
description: `GitHub Actions deploy role for ${githubRepo}@${deployBranch}`,
|
|
maxSessionDuration: Duration.hours(1),
|
|
assumedBy: new iam.OpenIdConnectPrincipal(provider, oidcConditions),
|
|
});
|
|
|
|
// Dev's reusable CDK workflow needs the shared bootstrap roles. Staging is
|
|
// intentionally narrower: its recurring promotion workflow only publishes
|
|
// application assets to this stack's bucket/distribution. Infrastructure
|
|
// changes remain an administrator-run CDK operation, so the staging OIDC
|
|
// role cannot inherit the bootstrap roles' account-wide deployment power.
|
|
if (!githubEnvironment) {
|
|
deployRole.addToPolicy(
|
|
new iam.PolicyStatement({
|
|
sid: "AssumeCdkBootstrapRoles",
|
|
actions: ["sts:AssumeRole"],
|
|
resources: [`arn:aws:iam::${this.account}:role/cdk-hnb659fds-*`],
|
|
}),
|
|
);
|
|
}
|
|
deployRole.addToPolicy(
|
|
new iam.PolicyStatement({
|
|
sid: "DescribeStack",
|
|
actions: ["cloudformation:DescribeStacks"],
|
|
resources: [
|
|
`arn:aws:cloudformation:${this.region}:${this.account}:stack/${this.stackName}/*`,
|
|
],
|
|
}),
|
|
);
|
|
bucket.grantReadWrite(deployRole);
|
|
deployRole.addToPolicy(
|
|
new iam.PolicyStatement({
|
|
sid: "InvalidateDistribution",
|
|
actions: ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"],
|
|
resources: [
|
|
`arn:aws:cloudfront::${this.account}:distribution/${distribution.distributionId}`,
|
|
],
|
|
}),
|
|
);
|
|
|
|
// --- DNS: point the custom domain at CloudFront ------------------------
|
|
// Only when a hosted zone is supplied (it must be in THIS account). Creates
|
|
// A + AAAA aliases; for the zone apex, recordName is the zone itself.
|
|
if (hostedZoneId && hasCustomDomain) {
|
|
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "Zone", {
|
|
hostedZoneId,
|
|
zoneName: hostedZoneName,
|
|
});
|
|
const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution));
|
|
// apex record when the domain equals the zone name.
|
|
const recordName = domainNames[0] === hostedZoneName ? undefined : domainNames[0];
|
|
|
|
new route53.ARecord(this, "AliasA", { zone, recordName, target });
|
|
new route53.AaaaRecord(this, "AliasAAAA", { zone, recordName, target });
|
|
}
|
|
|
|
// --- Outputs -----------------------------------------------------------
|
|
// scripts/deploy-web.sh reads BucketName + DistributionId from these.
|
|
new CfnOutput(this, "SiteUrl", {
|
|
value: hasCustomDomain
|
|
? `https://${domainNames[0]}`
|
|
: `https://${distribution.distributionDomainName}`,
|
|
description: "Public URL of the deployed SPA",
|
|
});
|
|
new CfnOutput(this, "DistributionDomainName", {
|
|
value: distribution.distributionDomainName,
|
|
description: "CloudFront domain — point the custom-domain DNS record here",
|
|
});
|
|
new CfnOutput(this, "BucketName", {
|
|
value: bucket.bucketName,
|
|
});
|
|
new CfnOutput(this, "DistributionId", {
|
|
value: distribution.distributionId,
|
|
});
|
|
new CfnOutput(this, "DeployRoleArn", {
|
|
value: deployRole.roleArn,
|
|
description: "-> GitHub repo secret AWS_DEPLOY_ROLE_ARN",
|
|
});
|
|
}
|
|
}
|