Skip to content

Commit 7d3bcd5

Browse files
committed
feat: add optional S3 access logging for Control Tower compliance (CT.S3.PR.2)
Add optional s3_access_logs_bucket_arn parameter to sagemaker-templates and sagemaker-model-cicd modules. When provided, all S3 buckets created by these modules will configure server access logging to the specified bucket, satisfying the CT.S3.PR.2 proactive control that blocks bucket creation without logging in Control Tower environments. Key changes: - sagemaker-templates: Wire parameter through settings/stack to xgboost_abalone (2 buckets) and batch_inference (1 bucket) - sagemaker-model-cicd: Wire parameter through Zod schema/props to all 3 pipeline artifact buckets and the LogsBucket - Log prefixes include project/pipeline/bucket names for disambiguation when multiple module instances share the same logging bucket - CDK Nag AwsSolutions-S1 suppressions are conditional: only applied when logging is not configured - Unit tests cover both logging-enabled and logging-disabled paths
1 parent e41c5f2 commit 7d3bcd5

17 files changed

Lines changed: 460 additions & 175 deletions

modules/sagemaker/sagemaker-model-cicd/bin/seedfarmer-parameters.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ export function getModuleParameters() {
5858
const deploymentGroups = getSeedFarmerParamter('DEPLOYMENT_GROUPS');
5959
const permissionsBoundaryName =
6060
process.env.SEEDFARMER_PARAMETER_PERMISSIONS_BOUNDARY_NAME;
61+
const s3AccessLogsBucketArn =
62+
process.env.SEEDFARMER_PARAMETER_S3_ACCESS_LOGS_BUCKET_ARN;
6163

6264
// validate parameters
6365
try {
@@ -67,6 +69,7 @@ export function getModuleParameters() {
6769
modelBuildRepo,
6870
deploymentGroups,
6971
permissionsBoundaryName,
72+
s3AccessLogsBucketArn,
7073
});
7174
} catch (err) {
7275
const validationError = fromError(err, {

modules/sagemaker/sagemaker-model-cicd/lib/code-pipeline/mlops-code-pipeline-stack-props.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ export const MLOpsCodePipelinePropsSchema = z.object({
123123
* IAM Policy Name to attach to all roles as permissions boundary.
124124
*/
125125
permissionsBoundaryName: z.string().optional(),
126+
/**
127+
* S3 bucket ARN for server access logging. When provided, all S3 buckets
128+
* will be configured to send access logs to this bucket.
129+
*/
130+
s3AccessLogsBucketArn: z.string().optional(),
126131
});
127132

128133
export type MLOpsCodePipelineStackProps = z.infer<

modules/sagemaker/sagemaker-model-cicd/lib/code-pipeline/mlops-code-pipeline-stack.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export class MLOpsCodePipelineStack extends cdk.Stack {
5353
modelBuildRepo,
5454
deploymentGroups,
5555
tags,
56+
s3AccessLogsBucketArn,
5657
} = props;
5758

5859
// get the infra codecommit repo that will be used as source for model deploy codepipeline
@@ -78,7 +79,7 @@ export class MLOpsCodePipelineStack extends cdk.Stack {
7879
// this pipeline will already be updated during `seedfarmer apply`
7980
selfMutation: false,
8081
crossAccountKeys: true,
81-
artifactBucket: utils.createPipelineArtifactsBucket(this),
82+
artifactBucket: utils.createPipelineArtifactsBucket(this, s3AccessLogsBucketArn, `${projectName}-infra-pipeline-artifacts/`),
8283
synth: new cdk.pipelines.CodeBuildStep('Synth', {
8384
input: cdk.pipelines.CodePipelineSource.codeCommit(
8485
this.infraRepo,
@@ -127,6 +128,7 @@ export class MLOpsCodePipelineStack extends cdk.Stack {
127128
modelPackageGroupName,
128129
modelApprovalTopicName: modelApprovalNotificationsTopicName,
129130
deployEnvironments,
131+
s3AccessLogsBucketArn,
130132
// Model build support resources stack should be deployed to model build account
131133
env: {
132134
account: buildEnvironment.account,
@@ -153,6 +155,7 @@ export class MLOpsCodePipelineStack extends cdk.Stack {
153155
sagemakerExecutionRoleName,
154156
codeBuildAssumeRoleName,
155157
modelPackageGroupName,
158+
s3AccessLogsBucketArn,
156159
// this pipeline should be created in tooling account, which will trigger the model build in target account
157160
env: toolingEnvironment,
158161
description: `Model build pipeline for ${projectName} ${deploymentGroup.name}`,
@@ -175,6 +178,7 @@ export class MLOpsCodePipelineStack extends cdk.Stack {
175178
deploymentGroup,
176179
modelPackageGroupName,
177180
sagemakerArtifactsBucketName,
181+
s3AccessLogsBucketArn,
178182
// this pipeline should be created in tooling account, which will trigger the model deploy in target accounts
179183
env: toolingEnvironment,
180184
description: `Model deploy pipeline for ${projectName} ${deploymentGroup.name}`,

modules/sagemaker/sagemaker-model-cicd/lib/code-pipeline/model-build-support/model-build-support-stack.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as kms from 'aws-cdk-lib/aws-kms';
66
import * as s3 from 'aws-cdk-lib/aws-s3';
77
import * as sagemaker from 'aws-cdk-lib/aws-sagemaker';
88
import * as sns from 'aws-cdk-lib/aws-sns';
9+
import { NagSuppressions } from 'cdk-nag';
910
import { Construct } from 'constructs';
1011
import { DeployEnvironment } from '../mlops-code-pipeline-stack-props';
1112

@@ -18,6 +19,7 @@ export interface ModelBuildSupportStackProps extends cdk.StackProps {
1819
readonly modelPackageGroupName: string;
1920
readonly modelApprovalTopicName: string;
2021
readonly deployEnvironments: DeployEnvironment[];
22+
readonly s3AccessLogsBucketArn?: string;
2123
}
2224

2325
export class ModelBuildSupportStack extends cdk.Stack {
@@ -39,6 +41,7 @@ export class ModelBuildSupportStack extends cdk.Stack {
3941
modelApprovalTopicName,
4042
deployEnvironments,
4143
toolingEnvironment,
44+
s3AccessLogsBucketArn,
4245
} = props;
4346

4447
const deploymentAccountPrincipals: iam.AccountPrincipal[] =
@@ -87,14 +90,29 @@ export class ModelBuildSupportStack extends cdk.Stack {
8790
targetKey: kmsKey,
8891
});
8992

93+
const accessLogsBucket = s3AccessLogsBucketArn
94+
? s3.Bucket.fromBucketArn(this, 'AccessLogsBucket', s3AccessLogsBucketArn)
95+
: undefined;
96+
9097
const logsBucket = new s3.Bucket(this, 'LogsBucket', {
9198
encryption: s3.BucketEncryption.KMS,
9299
encryptionKey: kmsKey,
93100
removalPolicy: cdk.RemovalPolicy.DESTROY,
94101
autoDeleteObjects: true,
95102
enforceSSL: true,
103+
serverAccessLogsBucket: accessLogsBucket,
104+
serverAccessLogsPrefix: accessLogsBucket ? `${sagemakerArtifactsBucketName}-logs/` : undefined,
96105
});
97106

107+
if (!accessLogsBucket) {
108+
NagSuppressions.addResourceSuppressions(logsBucket, [
109+
{
110+
id: 'AwsSolutions-S1',
111+
reason: 'This is itself a logging bucket; enabling access logs would create a circular dependency.',
112+
},
113+
]);
114+
}
115+
98116
const sagemakerArtifactsBucket = new s3.Bucket(this, 'ArtifactsBucket', {
99117
bucketName: sagemakerArtifactsBucketName,
100118
encryption: s3.BucketEncryption.KMS,

modules/sagemaker/sagemaker-model-cicd/lib/code-pipeline/model-build/model-build-code-pipeline-stack.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface ModelBuildCodePipelineProps extends cdk.StackProps {
1919
readonly sagemakerExecutionRoleName: string;
2020
readonly codeBuildAssumeRoleName: string;
2121
readonly modelPackageGroupName: string;
22+
readonly s3AccessLogsBucketArn?: string;
2223
}
2324

2425
export class ModelBuildCodePipelineStack extends cdk.Stack {
@@ -118,7 +119,7 @@ export class ModelBuildCodePipelineStack extends cdk.Stack {
118119

119120
const buildPipeline = new codepipeline.Pipeline(this, 'BuildPipeline', {
120121
pipelineName,
121-
artifactBucket: utils.createPipelineArtifactsBucket(this),
122+
artifactBucket: utils.createPipelineArtifactsBucket(this, props.s3AccessLogsBucketArn, `${pipelineName}-artifacts/`),
122123
});
123124
this.pipeline = buildPipeline;
124125

modules/sagemaker/sagemaker-model-cicd/lib/code-pipeline/model-deploy/model-deploy-code-pipeline-stack.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface ModelDeployCodePipelineStackProps extends cdk.StackProps {
2828
readonly ssmParamName?: string;
2929
readonly modelPackageGroupName: string;
3030
readonly sagemakerArtifactsBucketName: string;
31+
readonly s3AccessLogsBucketArn?: string;
3132
}
3233

3334
export class ModelDeployCodePipelineStack extends cdk.Stack {
@@ -89,7 +90,7 @@ export class ModelDeployCodePipelineStack extends cdk.Stack {
8990
// this pipeline will be updated by project-infra pipeline
9091
selfMutation: false,
9192
crossAccountKeys: true,
92-
artifactBucket: utils.createPipelineArtifactsBucket(this),
93+
artifactBucket: utils.createPipelineArtifactsBucket(this, props.s3AccessLogsBucketArn, `${pipelineName}-artifacts/`),
9394
synth: new cdk.pipelines.CodeBuildStep('Synth', {
9495
input: cdk.pipelines.CodePipelineSource.codeCommit(
9596
infraRepo,

modules/sagemaker/sagemaker-model-cicd/lib/utils/pipeline-artifacts-bucket.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import * as s3 from 'aws-cdk-lib/aws-s3';
44
import { NagSuppressions } from 'cdk-nag';
55
import { Construct } from 'constructs';
66

7-
export function createPipelineArtifactsBucket(scope: Construct): s3.Bucket {
7+
export function createPipelineArtifactsBucket(
8+
scope: Construct,
9+
s3AccessLogsBucketArn?: string,
10+
logsPrefix?: string,
11+
): s3.Bucket {
812
const removalPolicy = cdk.RemovalPolicy.DESTROY;
913
const autoDeleteObjects = removalPolicy === cdk.RemovalPolicy.DESTROY;
1014

@@ -17,6 +21,10 @@ export function createPipelineArtifactsBucket(scope: Construct): s3.Bucket {
1721
},
1822
);
1923

24+
const accessLogsBucket = s3AccessLogsBucketArn
25+
? s3.Bucket.fromBucketArn(scope, 'PipelineAccessLogsBucket', s3AccessLogsBucketArn)
26+
: undefined;
27+
2028
const pipelineArtifactsBucket = new s3.Bucket(
2129
scope,
2230
'PipelineArtifactBucket',
@@ -26,13 +34,17 @@ export function createPipelineArtifactsBucket(scope: Construct): s3.Bucket {
2634
autoDeleteObjects,
2735
bucketKeyEnabled: true,
2836
enforceSSL: true,
37+
serverAccessLogsBucket: accessLogsBucket,
38+
serverAccessLogsPrefix: accessLogsBucket ? (logsPrefix || 'pipeline-artifacts/') : undefined,
2939
},
3040
);
31-
NagSuppressions.addResourceSuppressions(pipelineArtifactsBucket, [
32-
{
33-
id: 'AwsSolutions-S1',
34-
reason: 'The bucket stores pipeline artifacts, no logging required.',
35-
},
36-
]);
41+
if (!accessLogsBucket) {
42+
NagSuppressions.addResourceSuppressions(pipelineArtifactsBucket, [
43+
{
44+
id: 'AwsSolutions-S1',
45+
reason: 'The bucket stores pipeline artifacts, no logging required.',
46+
},
47+
]);
48+
}
3749
return pipelineArtifactsBucket;
3850
}

modules/sagemaker/sagemaker-model-cicd/test/__snapshots__/model-build-support-stack.test.ts.snap

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,16 @@ exports[`ModelBuildSupportStack to match snapshot 1`] = `
604604
},
605605
"LogsBucket9C4D8843": {
606606
"DeletionPolicy": "Delete",
607+
"Metadata": {
608+
"cdk_nag": {
609+
"rules_to_suppress": [
610+
{
611+
"id": "AwsSolutions-S1",
612+
"reason": "This is itself a logging bucket; enabling access logs would create a circular dependency.",
613+
},
614+
],
615+
},
616+
},
607617
"Properties": {
608618
"AccessControl": "LogDeliveryWrite",
609619
"BucketEncryption": {

modules/sagemaker/sagemaker-model-cicd/test/mlops-code-pipeline-stack.test.ts

Lines changed: 77 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,69 +2,87 @@ import * as cdk from 'aws-cdk-lib';
22
import { Template } from 'aws-cdk-lib/assertions';
33
import { MLOpsCodePipelineStack } from '../lib';
44

5-
test('MLOpsCodePipelineStack to match snapshot', () => {
6-
const app = new cdk.App();
7-
const stack = new MLOpsCodePipelineStack(app, 'MyTestStack', {
8-
env: { account: '123456789011', region: 'us-east-1' }, // tooling account
9-
projectName: 'TestProject',
10-
deploymentGroups: [
11-
// experimenation deployment group to train model on test dataset
12-
{
13-
name: 'experimenation-deployment-group',
14-
sourceBranch: 'dev',
15-
buildEnvironment: {
16-
name: 'experiment-env1',
17-
account: '123456789012',
18-
region: 'us-east-1',
19-
type: 'dev',
20-
},
21-
deployEnvironments: [
22-
{
23-
name: 'experiment-env2',
24-
account: '123456789013',
25-
type: 'dev',
26-
},
27-
{
28-
name: 'experiment-env3',
29-
account: '123456789014',
30-
type: 'dev',
31-
},
32-
],
5+
const baseProps = {
6+
env: { account: '123456789011', region: 'us-east-1' }, // tooling account
7+
projectName: 'TestProject',
8+
deploymentGroups: [
9+
// experimenation deployment group to train model on test dataset
10+
{
11+
name: 'experimenation-deployment-group',
12+
sourceBranch: 'dev',
13+
buildEnvironment: {
14+
name: 'experiment-env1',
15+
account: '123456789012',
16+
region: 'us-east-1',
17+
type: 'dev' as const,
3318
},
34-
// prod deployment group with retraining on prod dataset
35-
{
36-
name: 'prod-deployment-group',
37-
sourceBranch: 'main',
38-
buildEnvironment: {
39-
name: 'prod-train-env',
40-
account: '123456789015',
41-
region: 'us-east-1',
42-
type: 'preprod',
19+
deployEnvironments: [
20+
{
21+
name: 'experiment-env2',
22+
account: '123456789013',
23+
type: 'dev' as const,
4324
},
44-
deployEnvironments: [
45-
{
46-
name: 'prod-approval-env',
47-
account: '123456789016',
48-
type: 'preprod',
49-
},
50-
{
51-
name: 'prod-env',
52-
account: '123456789017',
53-
type: 'prod',
54-
},
55-
],
56-
},
57-
],
58-
modelBuildRepo: {
59-
host: 'codecommit',
60-
name: 'model-build-repo',
25+
{
26+
name: 'experiment-env3',
27+
account: '123456789014',
28+
type: 'dev' as const,
29+
},
30+
],
6131
},
62-
infraRepo: {
63-
host: 'codecommit',
64-
name: 'model-deploy-repo',
65-
branch: 'main',
32+
// prod deployment group with retraining on prod dataset
33+
{
34+
name: 'prod-deployment-group',
35+
sourceBranch: 'main',
36+
buildEnvironment: {
37+
name: 'prod-train-env',
38+
account: '123456789015',
39+
region: 'us-east-1',
40+
type: 'preprod' as const,
41+
},
42+
deployEnvironments: [
43+
{
44+
name: 'prod-approval-env',
45+
account: '123456789016',
46+
type: 'preprod' as const,
47+
},
48+
{
49+
name: 'prod-env',
50+
account: '123456789017',
51+
type: 'prod' as const,
52+
},
53+
],
6654
},
67-
});
55+
],
56+
modelBuildRepo: {
57+
host: 'codecommit' as const,
58+
name: 'model-build-repo',
59+
},
60+
infraRepo: {
61+
host: 'codecommit' as const,
62+
name: 'model-deploy-repo',
63+
branch: 'main',
64+
},
65+
};
66+
67+
test('MLOpsCodePipelineStack to match snapshot', () => {
68+
const app = new cdk.App();
69+
const stack = new MLOpsCodePipelineStack(app, 'MyTestStack', baseProps);
6870
const template = Template.fromStack(stack);
6971
expect(template.toJSON()).toMatchSnapshot();
7072
});
73+
74+
test('MLOpsCodePipelineStack with s3AccessLogsBucketArn configures logging on artifact bucket', () => {
75+
const app = new cdk.App();
76+
const stack = new MLOpsCodePipelineStack(app, 'MyTestStack', {
77+
...baseProps,
78+
s3AccessLogsBucketArn: 'arn:aws:s3:::test-access-logs-bucket',
79+
});
80+
const template = Template.fromStack(stack);
81+
82+
template.hasResourceProperties('AWS::S3::Bucket', {
83+
LoggingConfiguration: {
84+
DestinationBucketName: 'test-access-logs-bucket',
85+
LogFilePrefix: 'TestProject-infra-pipeline-artifacts/',
86+
},
87+
});
88+
});

0 commit comments

Comments
 (0)