diff --git a/go.mod b/go.mod index 95573479..6ffed06d 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/rds v1.91.0 // indirect github.com/aws/aws-sdk-go-v2/service/redshift v1.65.4 // indirect github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2 // indirect + github.com/aws/aws-sdk-go-v2/service/s3tables v1.18.4 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/ssm v1.56.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect diff --git a/go.sum b/go.sum index 04e9fddc..67d002ac 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2 h1:wmt05tPp/CaRZpPV5B4SaJ5T github.com/aws/aws-sdk-go-v2/service/route53 v1.46.2/go.mod h1:d+K9HESMpGb1EU9/UmmpInbGIUcAkwmcY6ZO/A3zZsw= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= +github.com/aws/aws-sdk-go-v2/service/s3tables v1.18.4 h1:Dw488RJo3tscyq5pzpT/BIGZcfZ7GoIE+S06AG4q8ik= +github.com/aws/aws-sdk-go-v2/service/s3tables v1.18.4/go.mod h1:7PsCRtQnxct6wWtIRr6glZNE8rDpqV+UraHwmZJMK1c= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6 h1:1KDMKvOKNrpD667ORbZ/+4OgvUoaok1gg/MLzrHF9fw= github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.34.6/go.mod h1:DmtyfCfONhOyVAJ6ZMTrDSFIeyCBlEO93Qkfhxwbxu0= github.com/aws/aws-sdk-go-v2/service/servicediscovery v1.42.0 h1:2UqEBrJPyfw9guK/JuInuQkWp+3Z6Hw2eySe//LQk50= diff --git a/integ/aws/storage/Makefile b/integ/aws/storage/Makefile index 56e32fe4..a977df53 100644 --- a/integ/aws/storage/Makefile +++ b/integ/aws/storage/Makefile @@ -63,3 +63,7 @@ bucket-notifications: ## Test S3 Bucket with EventBridge Notifications backup.plan: ## Test Backup Plan/Vault/Selection L2s (live plan over a DynamoDB table) go test -v -count 1 -timeout 30m ./... -run ^TestBackupPlan$ .PHONY: backup.plan + +s3tables.table: ## Test S3 Tables TableBucket/Namespace/Table L2s (live ICEBERG table) + go test -v -count 1 -timeout 30m ./... -run ^TestS3TablesTable$ +.PHONY: s3tables.table diff --git a/integ/aws/storage/apps/s3tables.table.ts b/integ/aws/storage/apps/s3tables.table.ts new file mode 100644 index 00000000..e27e0929 --- /dev/null +++ b/integ/aws/storage/apps/s3tables.table.ts @@ -0,0 +1,90 @@ +// Live test for the storage.s3tables L2s (alpha port): a real S3 Tables +// TableBucket + Namespace + ICEBERG Table with a compaction-only maintenance +// configuration — the one-sided maintenance_configuration shape (absent +// snapshot_management member rendered null-filled) proven against live AWS, +// including the post-apply drift oracle. +import { App, LocalBackend, TerraformOutput } from "cdktn"; +import { aws } from "../../../../src"; + +const environmentName = process.env.ENVIRONMENT_NAME ?? "test"; +const region = process.env.AWS_REGION ?? "us-east-1"; +const outdir = process.env.OUT_DIR ?? "cdktf.out"; +const stackName = process.env.STACK_NAME ?? "s3tables.table"; + +const app = new App({ + outdir, +}); + +const stack = new aws.AwsStack(app, stackName, { + gridUUID: "geeeeeeee-eeee", + environmentName, + providerConfig: { + region, + }, +}); +new LocalBackend(stack, { + path: `${stackName}.tfstate`, +}); + +const bucket = new aws.storage.s3tables.TableBucket(stack, "Bucket", { + tableBucketName: "tcons-s3tables-integ", + // Terraform-native destroy of a non-empty table bucket. + forceDestroy: true, +}); + +const namespace = new aws.storage.s3tables.Namespace(stack, "Namespace", { + namespaceName: "integ_ns", + tableBucket: bucket, +}); + +const table = new aws.storage.s3tables.Table(stack, "Table", { + tableName: "integ_table", + namespace, + openTableFormat: aws.storage.s3tables.OpenTableFormat.ICEBERG, + // Compaction WITHOUT snapshotManagement: the one-sided maintenance shape + // (absent side rendered with AWS's documented server-side defaults). + compaction: { + status: aws.storage.s3tables.Status.ENABLED, + targetFileSizeMb: 128, + }, +}); + +// The mirror one-sided shape: snapshotManagement WITHOUT compaction, proving +// the AWS-defaults fill for the compaction side too. +const snapshotTable = new aws.storage.s3tables.Table(stack, "SnapshotTable", { + tableName: "integ_snapshot_table", + namespace, + openTableFormat: aws.storage.s3tables.OpenTableFormat.ICEBERG, + snapshotManagement: { + status: aws.storage.s3tables.Status.ENABLED, + maxSnapshotAgeHours: 48, + minSnapshotsToKeep: 3, + }, +}); + +new TerraformOutput(stack, "table_bucket_arn", { + value: bucket.tableBucketArn, + staticId: true, +}); +new TerraformOutput(stack, "table_bucket_name", { + value: bucket.tableBucketName, + staticId: true, +}); +new TerraformOutput(stack, "namespace_name", { + value: namespace.namespaceName, + staticId: true, +}); +new TerraformOutput(stack, "table_name", { + value: table.tableName, + staticId: true, +}); +new TerraformOutput(stack, "table_arn", { + value: table.tableArn, + staticId: true, +}); +new TerraformOutput(stack, "snapshot_table_name", { + value: snapshotTable.tableName, + staticId: true, +}); + +app.synth(); diff --git a/integ/aws/storage/s3tables_table_test.go b/integ/aws/storage/s3tables_table_test.go new file mode 100644 index 00000000..915a2979 --- /dev/null +++ b/integ/aws/storage/s3tables_table_test.go @@ -0,0 +1,107 @@ +package test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/s3tables" + s3tablestypes "github.com/aws/aws-sdk-go-v2/service/s3tables/types" + "github.com/gruntwork-io/terratest/modules/terraform" + test_structure "github.com/gruntwork-io/terratest/modules/test-structure" + "github.com/stretchr/testify/require" +) + +// Run the apps/s3tables.table.ts integration test: a real S3 Tables +// TableBucket + Namespace + ICEBERG Table through the storage.s3tables L2s +// (alpha port). Validates bucket/namespace/table read-back, the compaction-only +// maintenance_configuration shape (null-filled absent member), and the +// post-apply drift oracle. +func TestS3TablesTable(t *testing.T) { + runStorageIntegrationTest(t, "s3tables.table", "us-east-1", validateS3TablesTable) +} + +func validateS3TablesTable(t *testing.T, tfWorkingDir string, awsRegion string) { + terraformOptions := test_structure.LoadTerraformOptions(t, tfWorkingDir) + outputs := terraform.OutputAll(t, terraformOptions) + + bucketArn := outputs["table_bucket_arn"].(string) + bucketName := outputs["table_bucket_name"].(string) + namespaceName := outputs["namespace_name"].(string) + tableName := outputs["table_name"].(string) + tableArn := outputs["table_arn"].(string) + + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(awsRegion)) + require.NoError(t, err) + client := s3tables.NewFromConfig(cfg) + + // --- 1. Table bucket read-back. --- + gb, err := client.GetTableBucket(ctx, &s3tables.GetTableBucketInput{ + TableBucketARN: &bucketArn, + }) + require.NoError(t, err) + require.Equal(t, bucketName, *gb.Name) + t.Logf("s3tables: table bucket %s exists (%s)", bucketName, bucketArn) + + // --- 2. Namespace read-back. --- + gn, err := client.GetNamespace(ctx, &s3tables.GetNamespaceInput{ + TableBucketARN: &bucketArn, + Namespace: &namespaceName, + }) + require.NoError(t, err) + require.Contains(t, gn.Namespace, namespaceName) + t.Logf("s3tables: namespace %s exists", namespaceName) + + // --- 3. Table read-back: ICEBERG format + ARN. --- + gt, err := client.GetTable(ctx, &s3tables.GetTableInput{ + TableBucketARN: &bucketArn, + Namespace: &namespaceName, + Name: &tableName, + }) + require.NoError(t, err) + require.Equal(t, tableArn, *gt.TableARN) + require.Equal(t, s3tablestypes.OpenTableFormatIceberg, gt.Format) + t.Logf("s3tables: table %s exists (ICEBERG, %s)", tableName, tableArn) + + // --- 4. Maintenance read-back: compaction enabled at 128MB (the one-sided + // maintenance_configuration shape with a null-filled absent member). --- + gm, err := client.GetTableMaintenanceConfiguration(ctx, &s3tables.GetTableMaintenanceConfigurationInput{ + TableBucketARN: &bucketArn, + Namespace: &namespaceName, + Name: &tableName, + }) + require.NoError(t, err) + compaction, ok := gm.Configuration[string(s3tablestypes.TableMaintenanceTypeIcebergCompaction)] + require.True(t, ok, "compaction maintenance configuration must exist") + require.Equal(t, s3tablestypes.MaintenanceStatusEnabled, compaction.Status) + settings, ok := compaction.Settings.(*s3tablestypes.TableMaintenanceSettingsMemberIcebergCompaction) + require.True(t, ok, "compaction settings must be the iceberg compaction member") + require.Equal(t, int32(128), *settings.Value.TargetFileSizeMB) + t.Logf("s3tables: compaction enabled at %dMB target file size", *settings.Value.TargetFileSizeMB) + + // --- 4b. The mirror one-sided shape: snapshot-only table, compaction side + // filled with AWS defaults (enabled/512MB). --- + snapshotTableName := outputs["snapshot_table_name"].(string) + gm2, err := client.GetTableMaintenanceConfiguration(ctx, &s3tables.GetTableMaintenanceConfigurationInput{ + TableBucketARN: &bucketArn, + Namespace: &namespaceName, + Name: &snapshotTableName, + }) + require.NoError(t, err) + snap, ok := gm2.Configuration[string(s3tablestypes.TableMaintenanceTypeIcebergSnapshotManagement)] + require.True(t, ok, "snapshot management maintenance configuration must exist") + require.Equal(t, s3tablestypes.MaintenanceStatusEnabled, snap.Status) + snapSettings, ok := snap.Settings.(*s3tablestypes.TableMaintenanceSettingsMemberIcebergSnapshotManagement) + require.True(t, ok, "snapshot settings must be the iceberg snapshot management member") + require.Equal(t, int32(48), *snapSettings.Value.MaxSnapshotAgeHours) + require.Equal(t, int32(3), *snapSettings.Value.MinSnapshotsToKeep) + t.Logf("s3tables: snapshot-only table %s reads back 48h/3 snapshots", snapshotTableName) + + // --- Drift oracle: re-planning the already-applied stack must show zero + // changes. Proves the null-filled absent maintenance member reads back + // without perpetual diff. --- + planExitCode := terraform.PlanExitCode(t, terraformOptions) + require.Equal(t, terraform.DefaultSuccessExitCode, planExitCode, + "expected `tofu plan -detailed-exitcode` to report no drift after apply (got exit code %d)", planExitCode) +} diff --git a/src/aws/storage/index.ts b/src/aws/storage/index.ts index a7191328..d2fb932d 100644 --- a/src/aws/storage/index.ts +++ b/src/aws/storage/index.ts @@ -54,3 +54,6 @@ export * as redshift from "./redshift"; // aws-backup export * as backup from "./backup"; + +// aws-s3tables-alpha +export * as s3tables from "./s3tables"; diff --git a/src/aws/storage/s3tables/index.ts b/src/aws/storage/s3tables/index.ts new file mode 100644 index 00000000..ca7c5f4d --- /dev/null +++ b/src/aws/storage/s3tables/index.ts @@ -0,0 +1,25 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/index.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-s3tables-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. +// +// The index.ts files contains a list of files we want to include as part of the public API of +// this module. In general, all files including L2 classes will be listed here, while all files +// including only utility functions (`./permissions.ts`, `./util.ts`) are omitted, matching +// upstream's own convention verbatim. + +export * from "./table-bucket"; +export * from "./table-bucket-policy"; +export * from "./namespace"; +export * from "./table"; +export * from "./table-policy"; + +// TODO: omitted — upstream also re-exports the generated CFN L1 (`./s3tables.generated`, i.e. +// `CfnTableBucket`/`CfnTableBucketPolicy`/`CfnNamespace`/`CfnTable`/`CfnTablePolicy`). This repo +// has no CloudFormation-generated L1 layer to re-export (Terraform L1s come from +// `@cdktn/provider-aws` instead, already consumed directly by +// `./table-bucket.ts`/`./table-bucket-policy.ts`/`./namespace.ts`/`./table.ts`/`./table-policy.ts`) +// — identical omission to every other ported module in this repo (e.g. `../docdb/index.ts`, +// `../neptune/index.ts`, `../redshift/index.ts`) — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/index.ts diff --git a/src/aws/storage/s3tables/namespace.ts b/src/aws/storage/s3tables/namespace.ts new file mode 100644 index 00000000..fbf31e5f --- /dev/null +++ b/src/aws/storage/s3tables/namespace.ts @@ -0,0 +1,221 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/namespace.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-s3tables-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. + +import { EOL } from "node:os"; +import { s3TablesNamespace } from "@cdktn/provider-aws"; +import { Token } from "cdktn"; +import { Construct } from "constructs"; +import type { ITableBucket } from "./table-bucket"; +import { UnscopedValidationError } from "../../../errors"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; + +/** + * Represents an S3 Tables Namespace. + */ +export interface INamespace extends IAwsConstruct { + /** + * The name of this namespace + * @attribute + */ + readonly namespaceName: string; + + /** + * The table bucket which this namespace belongs to + * @attribute + */ + readonly tableBucket: ITableBucket; +} + +/** + * Parameters for constructing a Namespace + */ +export interface NamespaceProps extends AwsConstructProps { + /** + * A name for the namespace + * + * TERRACONSTRUCTS DEVIATION: kept required, matching upstream verbatim -- see the identical note + * on `TableBucketProps.tableBucketName` in `./table-bucket.ts` for the rationale (this repo's + * `uniqueResourceName` house-pattern default was deliberately not adopted here). + */ + readonly namespaceName: string; + /** + * The table bucket this namespace belongs to. + */ + readonly tableBucket: ITableBucket; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.DESTROY`, + // applied via `_resource.applyRemovalPolicy`) is CloudFormation's DeletionPolicy concept. + // `core.RemovalPolicy` is not ported anywhere in this repo (see the identical omission on + // `TableProps`/`TablePolicyProps` in `./table.ts`/`./table-policy.ts` and on every other ported + // module, e.g. `../docdb/cluster.ts`). `aws_s3tables_namespace` has no Terraform-native + // lifecycle replacement to offer here (unlike e.g. `skipFinalSnapshot` on `aws_neptune_cluster`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/namespace.ts#L40-L44 + // readonly removalPolicy?: RemovalPolicy; +} + +/** + * Attributes for importing an existing namespace + */ +export interface NamespaceAttributes { + /** + * The name of the namespace + */ + readonly namespaceName: string; + + /** + * The table bucket this namespace belongs to + */ + readonly tableBucket: ITableBucket; +} + +/** + * An S3 Tables Namespace with helpers. + * + * A namespace is a logical container for tables within a table bucket. + * + * @resource aws_s3tables_namespace + */ +export class Namespace extends AwsConstructBase implements INamespace { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.s3tables.Namespace"; + + /** + * Import an existing namespace from its attributes + */ + public static fromNamespaceAttributes( + scope: Construct, + id: string, + attrs: NamespaceAttributes, + ): INamespace { + class Import extends AwsConstructBase implements INamespace { + public readonly namespaceName = attrs.namespaceName; + public readonly tableBucket = attrs.tableBucket; + public get outputs(): Record { + return { + namespaceName: this.namespaceName, + }; + } + } + + return new Import(scope, id); + } + /** + * See https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-buckets-naming.html + * @param namespaceName Name of the namespace + * @throws UnscopedValidationError if any naming errors are detected + */ + public static validateNamespaceName(namespaceName: string) { + if (namespaceName == undefined || Token.isUnresolved(namespaceName)) { + // the name is a late-bound value, not a defined string, so skip validation + return; + } + + const errors: string[] = []; + + // Length validation + if (namespaceName.length < 1 || namespaceName.length > 255) { + errors.push( + "Namespace name must be at least 1 and no more than 255 characters", + ); + } + + // Character set validation + const illegalCharsetRegEx = /[^a-z0-9_]/; + const allowedEdgeCharsetRegEx = /[a-z0-9]/; + + const illegalCharMatch = namespaceName.match(illegalCharsetRegEx); + if (illegalCharMatch) { + errors.push( + "Namespace name must only contain lowercase characters, numbers, and underscores (_)" + + ` (offset: ${illegalCharMatch.index})`, + ); + } + + // Edge character validation + if (!allowedEdgeCharsetRegEx.test(namespaceName.charAt(0))) { + errors.push( + "Namespace name must start with a lowercase letter or number (offset: 0)", + ); + } + if ( + !allowedEdgeCharsetRegEx.test( + namespaceName.charAt(namespaceName.length - 1), + ) + ) { + errors.push( + `Namespace name must end with a lowercase letter or number (offset: ${ + namespaceName.length - 1 + })`, + ); + } + + if (namespaceName.startsWith("aws")) { + errors.push("Namespace name must not start with reserved prefix 'aws'"); + } + + if (errors.length > 0) { + throw new UnscopedValidationError( + `Invalid S3 Tables namespace name (value: ${namespaceName})${EOL}${errors.join(EOL)}`, + ); + } + } + + /** + * @internal The underlying namespace resource. + */ + private readonly _resource: s3TablesNamespace.S3TablesNamespace; + + /** + * The name of this namespace + */ + public readonly namespaceName: string; + + /** + * The table bucket which this namespace belongs to + */ + public readonly tableBucket: ITableBucket; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * e.g. `TableBucketBase.outputs` in `./table-bucket.ts`) — bare, bound-per-construct `outputs` + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + namespaceName: this.namespaceName, + tableBucketArn: this._resource.tableBucketArn, + }; + } + + constructor(scope: Construct, id: string, props: NamespaceProps) { + // NOTE: `props.account`/`props.region` are intentionally NOT forwarded here -- identical + // rationale to `TableBucket`'s constructor in `./table-bucket.ts` (see the note on + // `TableBucketProps.region` there): forwarding them would give this construct's + // `env.account`/`env.region` a literal value that defeats the + // `iam.Grant.addToPrincipalOrResource()` same-account short-circuit (`../../iam/grant.ts`), + // spuriously attaching a resource policy to every same-account grant. + super(scope, id, { ...props, account: undefined, region: undefined }); + + Namespace.validateNamespaceName(props.namespaceName); + + this.tableBucket = props.tableBucket; + this.namespaceName = props.namespaceName; + this._resource = new s3TablesNamespace.S3TablesNamespace(this, "Resource", { + namespace: props.namespaceName, + tableBucketArn: this.tableBucket.tableBucketArn, + }); + + // TODO: omitted — see the `removalPolicy` TODO on `NamespaceProps` above. + // if (props.removalPolicy) { + // this._resource.applyRemovalPolicy(props.removalPolicy); + // } + } +} diff --git a/src/aws/storage/s3tables/permissions.ts b/src/aws/storage/s3tables/permissions.ts new file mode 100644 index 00000000..15b42bb8 --- /dev/null +++ b/src/aws/storage/s3tables/permissions.ts @@ -0,0 +1,51 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/permissions.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-s3tables-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. + +// Table Bucket +// Read privileges +export const TABLE_BUCKET_READ_ACCESS = [ + "s3tables:Get*", + "s3tables:ListNamespaces", + "s3tables:ListTables", +]; + +// Write privileges +export const TABLE_BUCKET_WRITE_ACCESS = [ + "s3tables:PutTableData", + "s3tables:UpdateTableMetadataLocation", + "s3tables:CreateNamespace", + "s3tables:DeleteNamespace", + "s3tables:PutTableBucketMaintenanceConfiguration", + "s3tables:CreateTable", + "s3tables:RenameTable", +]; + +export const TABLE_BUCKET_READ_WRITE_ACCESS = [ + ...new Set([...TABLE_BUCKET_READ_ACCESS, ...TABLE_BUCKET_WRITE_ACCESS]), +]; + +// Table +// Read privileges +export const TABLE_READ_ACCESS = ["s3tables:Get*"]; +// Write privileges +export const TABLE_WRITE_ACCESS = [ + "s3tables:PutTableData", + "s3tables:UpdateTableMetadataLocation", + "s3tables:RenameTable", +]; +export const TABLE_READ_WRITE_ACCESS = [ + ...new Set([...TABLE_READ_ACCESS, ...TABLE_WRITE_ACCESS]), +]; + +// Permissions for user defined KMS Keys +// https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-kms-permissions.html +export const KEY_READ_ACCESS = ["kms:Decrypt"]; + +export const KEY_WRITE_ACCESS = ["kms:Decrypt", "kms:GenerateDataKey*"]; + +export const KEY_READ_WRITE_ACCESS = [ + ...new Set([...KEY_READ_ACCESS, ...KEY_WRITE_ACCESS]), +]; diff --git a/src/aws/storage/s3tables/table-bucket-policy.ts b/src/aws/storage/s3tables/table-bucket-policy.ts new file mode 100644 index 00000000..d6a9aa89 --- /dev/null +++ b/src/aws/storage/s3tables/table-bucket-policy.ts @@ -0,0 +1,98 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-s3tables-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. + +import { s3TablesTableBucketPolicy } from "@cdktn/provider-aws"; +import { Construct } from "constructs"; +import type { ITableBucket } from "./table-bucket"; +import { AwsConstructBase, AwsConstructProps } from "../../aws-construct"; +import * as iam from "../../iam"; + +/** + * Parameters for constructing a TableBucketPolicy + */ +export interface TableBucketPolicyProps extends AwsConstructProps { + /** + * The associated table bucket + */ + readonly tableBucket: ITableBucket; + + /** + * The policy document for the bucket's resource policy + * + * TERRACONSTRUCTS DEVIATION: this repo's `iam.PolicyDocument` is itself a Construct (it renders + * synth-time to a Terraform `aws_iam_policy_document` data source, see + * `../../iam/policy-document.ts`), unlike upstream's plain data-object `iam.PolicyDocument`. A + * fresh, scope-less `new iam.PolicyDocument({})` therefore cannot be used as the `@default` the + * way upstream does -- see the constructor below for the TERRACONSTRUCTS-native replacement. + * + * @default undefined An empty iam.PolicyDocument will be initialized + */ + readonly resourcePolicy?: iam.PolicyDocument; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.DESTROY`) is + // CloudFormation's DeletionPolicy concept. `core.RemovalPolicy` is not ported anywhere in this + // repo (identical omission to every other ported alpha module, e.g. `../redshift/cluster.ts`, + // `../docdb/cluster.ts`) -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket-policy.ts#L23-L27 + // readonly removalPolicy?: RemovalPolicy; +} + +/** + * A Bucket Policy for S3 TableBuckets. + * + * You will almost never need to use this construct directly. + * Instead, TableBucket.addToResourcePolicy can be used to add more policies to your bucket directly + * + * @resource aws_s3tables_table_bucket_policy + */ +export class TableBucketPolicy extends AwsConstructBase { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.s3tables.TableBucketPolicy"; + + /** + * The IAM PolicyDocument containing permissions represented by this policy. + */ + public readonly document: iam.PolicyDocument; + + /** + * The underlying policy resource. + * @internal + */ + private readonly _resource: s3TablesTableBucketPolicy.S3TablesTableBucketPolicy; + + public get outputs(): Record { + return { + tableBucketArn: this._resource.tableBucketArn, + }; + } + + constructor(scope: Construct, id: string, props: TableBucketPolicyProps) { + // NOTE: `props.account`/`props.region` are intentionally NOT forwarded here -- identical + // rationale to `TableBucket`'s constructor in `./table-bucket.ts` (see the note on + // `TableBucketProps.region` there): forwarding them would give this construct's + // `env.account`/`env.region` a literal value that defeats the + // `iam.Grant.addToPrincipalOrResource()` same-account short-circuit (`../../iam/grant.ts`), + // spuriously attaching a resource policy to every same-account grant. + super(scope, id, { ...props, account: undefined, region: undefined }); + + // Use default policy if not provided with props + this.document = + props.resourcePolicy ?? new iam.PolicyDocument(this, "Policy"); + + this._resource = new s3TablesTableBucketPolicy.S3TablesTableBucketPolicy( + this, + "Resource", + { + tableBucketArn: props.tableBucket.tableBucketArn, + resourcePolicy: this.document.json, + }, + ); + + // TODO: omitted — upstream's `props.removalPolicy ? this._resource.applyRemovalPolicy(...)`. + // See the TODO on `TableBucketPolicyProps.removalPolicy` above. + } +} diff --git a/src/aws/storage/s3tables/table-bucket.ts b/src/aws/storage/s3tables/table-bucket.ts new file mode 100644 index 00000000..f81cc429 --- /dev/null +++ b/src/aws/storage/s3tables/table-bucket.ts @@ -0,0 +1,884 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-s3tables-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. +// +// SCOPE REDUCTION (this PR): a few upstream surfaces have no Terraform-native equivalent or are +// CloudFormation-only mechanisms -- see the TERRACONSTRUCTS DEVIATION / TODO notes at each call +// site below: +// - `removalPolicy` -- OMITTED (commented out, not deleted): CloudFormation's +// DeletionPolicy concept. `core.RemovalPolicy` is not ported +// anywhere in this repo. +// - `requestMetricsStatus` / -- OMITTED (commented out, not deleted): the +// `RequestMetricsStatus` / `aws_s3tables_table_bucket` Terraform resource (provider 6.52.0) has +// `MetricsConfiguration` no `metrics_configuration` argument at all -- verified against +// the full config shape in +// `node_modules/@cdktn/provider-aws/lib/s3tables-table-bucket/index.d.ts`. +// - `ITaggableV2`/`cdkTagManager` -- OMITTED: this repo has no CDK-style `TagManager`. Any +// `aws_s3tables_table_bucket` (it has native `tags`/`tagsInput` +// arguments) is tagged automatically by the repo-wide +// `GridTags` Aspect (`../../construct-base.ts`) -- identical +// omission to every other ported alpha module in this repo. + +import { EOL } from "node:os"; +import { s3TablesTableBucket } from "@cdktn/provider-aws"; +import { Token } from "cdktn"; +import { Construct } from "constructs"; +import * as perms from "./permissions"; +import { TableBucketPolicy } from "./table-bucket-policy"; +import { validateTableBucketAttributes } from "./util"; +import { UnscopedValidationError } from "../../../errors"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as kms from "../../encryption"; +import * as iam from "../../iam"; + +/** + * Interface definition for S3 Table Buckets + */ +export interface ITableBucket extends IAwsConstruct { + /** + * The ARN of the table bucket. + * @attribute + */ + readonly tableBucketArn: string; + + /** + * The name of the table bucket. + * @attribute + */ + readonly tableBucketName: string; + + /** + * The accountId containing the table bucket. + * @attribute + */ + readonly account?: string; + + /** + * The region containing the table bucket. + * @attribute + */ + readonly region?: string; + + /** + * Optional KMS encryption key associated with this table bucket. + */ + readonly encryptionKey?: kms.IKey; + + /** + * Adds a statement to the resource policy for a principal (i.e. + * account/role/service) to perform actions on this table bucket and/or its + * tables. + * + * Note that the policy statement may or may not be added to the policy. + * For example, when an `ITableBucket` is created from an existing table bucket, + * it's not possible to tell whether the bucket already has a policy + * attached, let alone to re-use that policy to add more statements to it. + * So it's safest to do nothing in these cases. + * + * @param statement the policy statement to be added to the bucket's + * policy. + * @returns metadata about the execution of this method. If the policy + * was not added, the value of `statementAdded` will be `false`. You + * should always check this value to make sure that the operation was + * actually carried out. Otherwise, synthesis and deploy will terminate + * silently, which may be confusing. + */ + addToResourcePolicy( + statement: iam.PolicyStatement, + ): iam.AddToResourcePolicyResult; + + /** + * Grant read permissions for this table bucket and its tables + * to an IAM principal (Role/Group/User). + * + * If encryption is used, permission to use the key to decrypt the contents + * of the bucket will also be granted to the same principal. + * + * @param identity The principal to allow read permissions to + * @param tableId Allow the permissions to all tables using '*' or to single table by its unique ID. + */ + grantRead(identity: iam.IGrantable, tableId: string): iam.Grant; + + /** + * Grant write permissions for this table bucket and its tables + * to an IAM principal (Role/Group/User). + * + * If encryption is used, permission to use the key to encrypt the contents + * of the bucket will also be granted to the same principal. + * + * @param identity The principal to allow write permissions to + * @param tableId Allow the permissions to all tables using '*' or to single table by its unique ID. + */ + grantWrite(identity: iam.IGrantable, tableId: string): iam.Grant; + + /** + * Grant read and write permissions for this table bucket and its tables + * to an IAM principal (Role/Group/User). + * + * If encryption is used, permission to use the key to encrypt/decrypt the contents + * of the bucket will also be granted to the same principal. + * + * @param identity The principal to allow read and write permissions to + * @param tableId Allow the permissions to all tables using '*' or to single table by its unique ID. + */ + grantReadWrite(identity: iam.IGrantable, tableId: string): iam.Grant; +} + +/** + * Unreferenced file removal settings for the this table bucket. + */ +export interface UnreferencedFileRemoval { + /** + * Duration after which noncurrent files should be removed. Should be at least one day. + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-table-buckets-maintenance.html + * + * @default - See S3 Tables User Guide + */ + readonly noncurrentDays?: number; + + /** + * Status of unreferenced file removal. Can be Enabled or Disabled. + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-table-buckets-maintenance.html + * + * @default - See S3 Tables User Guide + */ + readonly status?: UnreferencedFileRemovalStatus; + + /** + * Duration after which unreferenced files should be removed. Should be at least one day. + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-table-buckets-maintenance.html + * + * @default - See S3 Tables User Guide + */ + readonly unreferencedDays?: number; +} + +/** + * Controls whether unreferenced file removal is enabled or disabled. + */ +export enum UnreferencedFileRemovalStatus { + /** + * Enable unreferenced file removal. + */ + ENABLED = "Enabled", + + /** + * Disable unreferenced file removal. + */ + DISABLED = "Disabled", +} + +// TODO: omitted — upstream's `RequestMetricsStatus` enum (consumed by +// `TableBucketProps.requestMetricsStatus` below) has no Terraform-native equivalent. See the +// file-header SCOPE REDUCTION note -- +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts#L150-L163 +// export enum RequestMetricsStatus { +// ENABLED = 'Enabled', +// DISABLED = 'Disabled', +// } + +/** + * Controls Server Side Encryption (SSE) for this TableBucket. + */ +export enum TableBucketEncryption { + /** + * Use a customer defined KMS key for encryption + * If `encryptionKey` is specified, this key will be used, otherwise, one will be defined. + */ + KMS = "aws:kms", + + /** + * Use S3 managed encryption keys with AES256 encryption + */ + S3_MANAGED = "AES256", +} + +abstract class TableBucketBase + extends AwsConstructBase + implements ITableBucket +{ + public abstract readonly tableBucketArn: string; + public abstract readonly tableBucketName: string; + + /** + * The resource policy associated with this table bucket. + * + * If `autoCreatePolicy` is true, a `TableBucketPolicy` will be created upon the + * first call to addToResourcePolicy(s). + */ + public abstract tableBucketPolicy?: TableBucketPolicy; + + /** + * Indicates if a table bucket resource policy should automatically created upon + * the first call to `addToResourcePolicy`. + */ + protected abstract autoCreatePolicy: boolean; + + public abstract encryptionKey?: kms.IKey | undefined; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * e.g. `ClusterBase.outputs` in `../redshift/cluster.ts`) — bare, bound-per-construct `outputs` + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + tableBucketArn: this.tableBucketArn, + tableBucketName: this.tableBucketName, + }; + } + + /** + * Adds a statement to the resource policy for a principal (i.e. + * account/role/service) to perform actions on this table bucket and/or its + * contents. Use `tableBucketArn` and `arnForObjects(keys)` to obtain ARNs for + * this bucket or objects. + * + * Note that the policy statement may or may not be added to the policy. + * For example, when an `ITableBucket` is created from an existing table bucket, + * it's not possible to tell whether the bucket already has a policy + * attached, let alone to re-use that policy to add more statements to it. + * So it's safest to do nothing in these cases. + * + * @param statement the policy statement to be added to the bucket's + * policy. + * @returns metadata about the execution of this method. If the policy + * was not added, the value of `statementAdded` will be `false`. You + * should always check this value to make sure that the operation was + * actually carried out. Otherwise, synthesis and deploy will terminate + * silently, which may be confusing. + */ + public addToResourcePolicy( + statement: iam.PolicyStatement, + ): iam.AddToResourcePolicyResult { + if (!this.tableBucketPolicy && this.autoCreatePolicy) { + this.tableBucketPolicy = new TableBucketPolicy(this, "DefaultPolicy", { + tableBucket: this, + }); + } + + if (this.tableBucketPolicy) { + this.tableBucketPolicy.document.addStatements(statement); + return { statementAdded: true, policyDependable: this.tableBucketPolicy }; + } + + return { statementAdded: false }; + } + + /** + * [disable-awslint:no-grants] + */ + public grantRead(identity: iam.IGrantable, tableId: string) { + return this.grant( + identity, + perms.TABLE_BUCKET_READ_ACCESS, + perms.KEY_READ_ACCESS, + this.tableBucketArn, + this.getTableArn(tableId), + ); + } + + /** + * [disable-awslint:no-grants] + */ + public grantWrite(identity: iam.IGrantable, tableId: string) { + return this.grant( + identity, + perms.TABLE_BUCKET_WRITE_ACCESS, + perms.KEY_READ_WRITE_ACCESS, + this.tableBucketArn, + this.getTableArn(tableId), + ); + } + + /** + * [disable-awslint:no-grants] + */ + public grantReadWrite(identity: iam.IGrantable, tableId: string) { + return this.grant( + identity, + perms.TABLE_BUCKET_READ_WRITE_ACCESS, + perms.KEY_WRITE_ACCESS, + this.tableBucketArn, + this.getTableArn(tableId), + ); + } + + /** + * Grants the given s3tables permissions to the provided principal + * @returns Grant object + */ + private grant( + grantee: iam.IGrantable, + tableBucketActions: string[], + keyActions: string[], + resourceArn: string, + ...otherResourceArns: (string | undefined)[] + ) { + const resources = [resourceArn, ...otherResourceArns].filter( + (arn) => arn != undefined, + ); + + const grant = iam.Grant.addToPrincipalOrResource({ + grantee, + actions: tableBucketActions, + resourceArns: resources, + resource: this, + }); + + if (this.encryptionKey && keyActions && keyActions.length !== 0) { + this.encryptionKey.grant(grantee, ...keyActions); + } + + return grant; + } + + private getTableArn(tableId: string | undefined) { + return tableId ? `${this.tableBucketArn}/table/${tableId}` : undefined; + } +} + +/** + * Parameters for constructing a TableBucket + */ +export interface TableBucketProps extends AwsConstructProps { + /** + * Name of the S3 TableBucket. + * + * TERRACONSTRUCTS DEVIATION: kept required, matching upstream verbatim (CFN's + * `AWS::S3Tables::TableBucket.TableBucketName` is likewise required), rather than adopting this + * repo's `this.stack.uniqueResourceName(this, {...})` LOWERCASED-default house pattern used by + * sibling ports (e.g. `../docdb/cluster.ts`, `../neptune/cluster.ts`, + * `../elasticache/serverless-cache.ts`, `../backup/vault.ts`, `../table.ts`). S3 Tables bucket + * names are also S3-bucket-like (globally-scoped charset, 3-63 chars -- see + * `validateTableBucketName` below), same as those sibling constructs, so a default could be + * added later without a breaking change if desired. + * @link https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-buckets-naming.html#table-buckets-naming-rules + */ + readonly tableBucketName: string; + + /** + * Unreferenced file removal settings for the S3 TableBucket. + * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3tables-tablebucket-unreferencedfileremoval.html + * @default Enabled with default values + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-table-buckets-maintenance.html + */ + readonly unreferencedFileRemoval?: UnreferencedFileRemoval; + + // TERRACONSTRUCTS DEVIATION: upstream `TableBucketProps` separately re-declares its own + // `region`/`account` fields (with `@default` docs identical in spirit to + // `AwsConstructProps.region`/`.account` below) -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts#L327-L338 + // -- and, like this port, never forwards them to `super()` (verified against the upstream + // constructor body at + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts#L613-L639 + // -- only `physicalName` is passed through). Unlike upstream's plain CDK `ResourceProps`, this + // repo's `AwsConstructProps` (extended above) ALREADY declares `region?: string`/`account?: + // string` for environment-override purposes (see `../../aws-construct.ts`) -- re-declaring them + // here verbatim is not merely redundant, it is a hard jsii compile error (JSII5015: interfaces + // may not re-declare an inherited member, even with an identical type, because it is invalid + // C#). So this port relies on the inherited `AwsConstructProps.region`/`.account` fields instead + // of its own copies. + // + // Inherited `region`/`account` are still deliberately NOT forwarded to `super()` below, exactly + // as upstream ignores its own copies: doing so would give this construct's `env.account`/ + // `env.region` a literal value that diverges from sibling same-stack principals' (still-token) + // default account, which defeats the `iam.Grant.addToPrincipalOrResource()` same-account + // short-circuit (`../../iam/grant.ts`) and spuriously attaches a resource policy to every + // same-account role/user grant. See the constructor's `super(scope, id, { ...props, account: + // undefined, region: undefined })` call. + + /** + * The kind of server-side encryption to apply to this bucket. + * + * If you choose KMS, you can specify a KMS key via `encryptionKey`. If + * encryption key is not specified, a key will automatically be created. + * + * @default - `KMS` if `encryptionKey` is specified, or `S3_MANAGED` otherwise. + */ + readonly encryption?: TableBucketEncryption; + + /** + * External KMS key to use for bucket encryption. + * + * The `encryption` property must be either not specified or set to `KMS`. + * An error will be emitted if `encryption` is set to `S3_MANAGED`. + * + * @default - If `encryption` is set to `KMS` and this property is undefined, + * a new KMS key will be created and associated with this bucket. + */ + readonly encryptionKey?: kms.IKey; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.RETAIN`, + // applied via `_resource.applyRemovalPolicy`) is CloudFormation's DeletionPolicy concept. + // `core.RemovalPolicy` is not ported anywhere in this repo (see the identical omission on every + // other ported alpha module, e.g. `../redshift/cluster.ts`, `../docdb/cluster.ts`) -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts#L361-L366 + // readonly removalPolicy?: RemovalPolicy; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — native `aws_s3tables_table_bucket.force_destroy` + * argument (verified against + * `node_modules/@cdktn/provider-aws/lib/s3tables-table-bucket/index.d.ts`). When `true`, all + * tables and namespaces in the bucket are deleted when the bucket itself is deleted (Terraform + * will otherwise fail to destroy a non-empty table bucket). + * + * @default false + */ + readonly forceDestroy?: boolean; + + // TODO: omitted — upstream's `requestMetricsStatus?: RequestMetricsStatus`. See the file-header + // SCOPE REDUCTION note -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts#L369-L376 + // readonly requestMetricsStatus?: RequestMetricsStatus; +} + +/** + * A reference to a table bucket outside this stack + * + * The tableBucketName, region, and account can be provided explicitly + * or will be inferred from the tableBucketArn + */ +export interface TableBucketAttributes { + /** + * AWS region this table bucket exists in + * @default region inferred from scope + */ + readonly region?: string; + + /** + * The accountId containing this table bucket + * @default account inferred from scope + */ + readonly account?: string; + + /** + * The table bucket name, unique per region + * @default tableBucketName inferred from arn + */ + readonly tableBucketName?: string; + + /** + * The table bucket's ARN. + * @default tableBucketArn constructed from region, account and tableBucketName are provided + */ + readonly tableBucketArn?: string; + + /** + * Optional KMS encryption key associated with this bucket. + * @default - undefined + */ + readonly encryptionKey?: kms.IKey; +} + +/** + * An S3 table bucket with helpers for associated resource policies + * + * This bucket may not yet have all features that exposed by the underlying Terraform resource. + * + * @stateful + * @resource aws_s3tables_table_bucket + * @example + * const sampleTableBucket = new TableBucket(scope, 'ExampleTableBucket', { + * tableBucketName: 'example-bucket', + * // Optional fields: + * unreferencedFileRemoval: { + * noncurrentDays: 123, + * status: UnreferencedFileRemovalStatus.ENABLED, + * unreferencedDays: 123, + * }, + * }); + */ +export class TableBucket extends TableBucketBase { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.s3tables.TableBucket"; + + /** + * Defines a TableBucket construct from an external table bucket ARN. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param tableBucketArn Amazon Resource Name (arn) of the table bucket + */ + public static fromTableBucketArn( + scope: Construct, + id: string, + tableBucketArn: string, + ): ITableBucket { + return TableBucket.fromTableBucketAttributes(scope, id, { + tableBucketArn, + }); + } + + /** + * Defines a TableBucket construct that represents an external table bucket. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param attrs A `TableBucketAttributes` object. Can be manually created. + */ + public static fromTableBucketAttributes( + scope: Construct, + id: string, + attrs: TableBucketAttributes, + ): ITableBucket { + const { tableBucketName, region, account, tableBucketArn } = + validateTableBucketAttributes(scope, attrs); + TableBucket.validateTableBucketName(tableBucketName); + class Import extends TableBucketBase { + public readonly tableBucketName = tableBucketName!; + public readonly tableBucketArn = tableBucketArn; + public readonly tableBucketPolicy?: TableBucketPolicy; + public readonly region = region; + public readonly account = account; + public readonly encryptionKey?: kms.IKey = attrs.encryptionKey; + protected autoCreatePolicy: boolean = false; + } + + return new Import(scope, id, { + account, + region, + }); + } + + /** + * Throws an exception if the given table bucket name is not valid. + * + * @param bucketName name of the bucket. + */ + public static validateTableBucketName(bucketName: string | undefined) { + if (bucketName == undefined || Token.isUnresolved(bucketName)) { + // the name is a late-bound value, not a defined string, so skip validation + return; + } + + const errors: string[] = []; + + // Length validation + if (bucketName.length < 3 || bucketName.length > 63) { + errors.push( + "Bucket name must be at least 3 and no more than 63 characters", + ); + } + + // Character set validation + const illegalCharsetRegEx = /[^a-z0-9-]/; + const allowedEdgeCharsetRegEx = /[a-z0-9]/; + + const illegalCharMatch = bucketName.match(illegalCharsetRegEx); + if (illegalCharMatch) { + errors.push( + "Bucket name must only contain lowercase characters, numbers, and hyphens (-)" + + ` (offset: ${illegalCharMatch.index})`, + ); + } + + // Edge character validation + if (!allowedEdgeCharsetRegEx.test(bucketName.charAt(0))) { + errors.push( + "Bucket name must start with a lowercase letter or number (offset: 0)", + ); + } + if ( + !allowedEdgeCharsetRegEx.test(bucketName.charAt(bucketName.length - 1)) + ) { + errors.push( + `Bucket name must end with a lowercase letter or number (offset: ${ + bucketName.length - 1 + })`, + ); + } + + if (errors.length > 0) { + throw new UnscopedValidationError( + `Invalid S3 table bucket name (value: ${bucketName})${EOL}${errors.join(EOL)}`, + ); + } + } + + /** + * Throws an exception if the given unreferencedFileRemovalProperty is not valid. + * @param unreferencedFileRemoval configuration for the table bucket + */ + public static validateUnreferencedFileRemoval( + unreferencedFileRemoval?: UnreferencedFileRemoval, + ): void { + // Skip validation if property is not defined + if (!unreferencedFileRemoval) { + return; + } + + const { noncurrentDays, status, unreferencedDays } = + unreferencedFileRemoval; + + const errors: string[] = []; + + if (noncurrentDays != undefined) { + if (noncurrentDays < 1) { + errors.push("noncurrentDays must be at least 1 day"); + } + if (!Number.isInteger(noncurrentDays)) { + errors.push("noncurrentDays must be a whole number"); + } + } + + if (unreferencedDays != undefined) { + if (unreferencedDays < 1) { + errors.push("unreferencedDays must be at least 1 day"); + } + // TODO(alpha-tracker): upstream bug, kept verbatim for byte-closeness -- this checks + // `noncurrentDays` (the OTHER field) instead of `unreferencedDays`, so + // `{ unreferencedDays: }` with `noncurrentDays` undefined always throws + // (`Number.isInteger(undefined) === false`), and a non-integer `unreferencedDays` paired + // with an integer `noncurrentDays` is never caught. Re-diff when upstream fixes this -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts#L571-L578 + if (!Number.isInteger(noncurrentDays)) { + errors.push("unreferencedDays must be a whole number"); + } + } + + const allowedStatus = ["Enabled", "Disabled"]; + if (status != undefined && !allowedStatus.includes(status)) { + errors.push("status must be one of 'Enabled' or 'Disabled'"); + } + + if (errors.length > 0) { + throw new UnscopedValidationError( + `Invalid UnreferencedFileRemovalProperty})${EOL}${errors.join(EOL)}`, + ); + } + } + + /** + * The underlying Terraform L1 resource + * @internal + */ + private readonly resource: s3TablesTableBucket.S3TablesTableBucket; + + /** + * The resource policy for this tableBucket. + */ + public readonly tableBucketPolicy?: TableBucketPolicy; + + public readonly encryptionKey?: kms.IKey | undefined; + + protected autoCreatePolicy: boolean = true; + + constructor(scope: Construct, id: string, props: TableBucketProps) { + // NOTE: `props.account`/`props.region` are intentionally NOT forwarded here -- see the note on + // `TableBucketProps.region` above. + super(scope, id, { ...props, account: undefined, region: undefined }); + + TableBucket.validateTableBucketName(props.tableBucketName); + TableBucket.validateUnreferencedFileRemoval(props.unreferencedFileRemoval); + const { bucketEncryption, encryptionKey } = this.parseEncryption(props); + this.encryptionKey = encryptionKey; + + this.resource = new s3TablesTableBucket.S3TablesTableBucket( + this, + "Resource", + { + name: props.tableBucketName, + forceDestroy: props.forceDestroy, + maintenanceConfiguration: this.renderMaintenanceConfiguration( + props.unreferencedFileRemoval, + ), + encryptionConfiguration: bucketEncryption, + }, + ); + } + + /** + * The name of this table bucket + */ + public get tableBucketName(): string { + return this.resource.name; + } + + /** + * The unique Amazon Resource Name (arn) of this table bucket + */ + public get tableBucketArn(): string { + return this.resource.arn; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream — maps + * `UnreferencedFileRemoval.noncurrentDays`/`unreferencedDays`/`status` onto the provider's + * `maintenance_configuration.iceberg_unreferenced_file_removal` nested block (verified against + * `node_modules/@cdktn/provider-aws/lib/s3tables-table-bucket/index.d.ts`), which replaces the + * CFN `AWS::S3Tables::TableBucket.UnreferencedFileRemoval` top-level property upstream maps onto. + * Note the field-name difference: upstream/CFN `NoncurrentDays` -> provider `nonCurrentDays` + * (`non_current_days`). + * + * TERRACONSTRUCTS DEVIATION: two more divergences verified against the AWS provider (6.52.0, as + * bundled by `@cdktn/provider-aws` 24.8.0 -- see the doc links in + * `node_modules/@cdktn/provider-aws/lib/s3tables-table-bucket/index.d.ts`), neither present + * upstream (CFN tolerates partial/omitted nested properties): + * 1. `maintenance_configuration.iceberg_unreferenced_file_removal` is a Terraform framework + * `schema.ObjectAttribute` -- ALL of its members (`status`, `settings`, and, within + * `settings`, `non_current_days`/`unreferenced_days`) must be present in the rendered + * config whenever the block itself is present, even though every one of them is optional + * (nullable). `terraform validate` rejects a config that merely omits a member (rather than + * setting it to an explicit `null`) with e.g. "attribute \"settings\": attribute + * \"unreferenced_days\" is required." So the full object graph below is always rendered, + * substituting explicit `null` for any unset member. + * 2. `status`'s enum values are lowercase at the Terraform/API layer (`enabled`/`disabled` -- + * provider docs: "Valid values are `enabled` and `disabled`"), unlike CFN's + * `UnreferencedFileRemovalStatus`/`Enabled`/`Disabled`, which this port keeps (see + * `UnreferencedFileRemovalStatus` above) for upstream API fidelity. Lower-cased here at + * render time rather than by changing the public enum's literal values. + */ + private renderMaintenanceConfiguration( + unreferencedFileRemoval?: UnreferencedFileRemoval, + ) { + if (!unreferencedFileRemoval) { + return undefined; + } + const icebergUnreferencedFileRemoval: s3TablesTableBucket.S3TablesTableBucketMaintenanceConfigurationIcebergUnreferencedFileRemoval = + { + status: (unreferencedFileRemoval.status + ? unreferencedFileRemoval.status.toLowerCase() + : null) as any, + settings: { + nonCurrentDays: (unreferencedFileRemoval.noncurrentDays ?? + null) as any, + unreferencedDays: (unreferencedFileRemoval.unreferencedDays ?? + null) as any, + }, + }; + return { + icebergUnreferencedFileRemoval, + }; + } + + /** + * Set up key properties and return the Bucket encryption property from the + * user's configuration, according to the following table: + * + * | props.encryption | props.encryptionKey | bucketEncryption (return value) | encryptionKey (return value) | + * |------------------|---------------------|---------------------------------|-------------------------------| + * | undefined | undefined | undefined | undefined | + * | undefined | k | aws:kms | k | + * | KMS | undefined | aws:kms | new key (allow maintenance SP)| + * | KMS | k | aws:kms | k | + * | S3_MANAGED | undefined | AES256 | undefined | + * | S3_MANAGED | k | ERROR! | ERROR! | + */ + private parseEncryption(props: TableBucketProps): { + bucketEncryption?: s3TablesTableBucket.S3TablesTableBucketEncryptionConfiguration; + encryptionKey?: kms.IKey; + } { + const encryptionType = props.encryption; + let key = props.encryptionKey; + + if (encryptionType === undefined) { + if (key === undefined) { + return { bucketEncryption: undefined, encryptionKey: undefined }; + } else { + return { + bucketEncryption: { + // ID-vs-ARN AUDIT: fed the KMS key ARN (matches upstream's `key.keyArn`; the provider + // argument is named `kmsKeyArn`, unlike some other Terraform resources in this repo's + // house pattern that (confusingly) accept a bare key id under a `kmsKeyId`-named + // argument). + kmsKeyArn: key.keyArn, + sseAlgorithm: TableBucketEncryption.KMS, + }, + encryptionKey: key, + }; + } + } + + if (encryptionType === TableBucketEncryption.KMS) { + if (key === undefined) { + key = new kms.Key(this, "Key", { + description: `Created by ${this.node.path}`, + enableKeyRotation: true, + }); + this.allowTablesMaintenanceAccessToKey(key, props.tableBucketName); + } + return { + bucketEncryption: { + kmsKeyArn: key.keyArn, + sseAlgorithm: TableBucketEncryption.KMS, + }, + encryptionKey: key, + }; + } + + if (encryptionType === TableBucketEncryption.S3_MANAGED) { + if (key === undefined) { + return { + bucketEncryption: { + sseAlgorithm: TableBucketEncryption.S3_MANAGED, + // TERRACONSTRUCTS DEVIATION: not present upstream (CFN's + // `EncryptionConfiguration.KMSKeyArn` can simply be omitted). Verified against the AWS + // provider (6.52.0, as bundled by `@cdktn/provider-aws` 24.8.0): + // `aws_s3tables_table_bucket.encryption_configuration` is a + // Terraform framework `schema.ObjectAttribute`, whose members must ALL be present in + // the rendered config -- `terraform validate` rejects an `encryption_configuration` + // block that omits `kms_key_arn` entirely with "attribute \"kms_key_arn\" is required", + // even though the field itself is optional (nullable). Render it explicitly as `null`. + kmsKeyArn: null as any, + }, + }; + } else { + throw new UnscopedValidationError( + "Expected encryption = `KMS` with user provided encryption key", + ); + } + } + throw new UnscopedValidationError( + `Unknown encryption configuration detected: ${props.encryption} with key ${props.encryptionKey}`, + ); + } + + /** + * Allowlist S3 Tables Maintenance to access this table bucket's encryption key + * + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-kms-permissions.html + * @param encryptionKey The key to provide access to + */ + private allowTablesMaintenanceAccessToKey( + encryptionKey: kms.IKey, + tableBucketName: string, + ) { + const region = this.stack.region; + const account = this.stack.account; + const partition = this.stack.partition; + + encryptionKey.addToResourcePolicy( + new iam.PolicyStatement({ + sid: "AllowS3TablesMaintenanceAccess", + effect: iam.Effect.ALLOW, + principals: [ + new iam.ServicePrincipal("maintenance.s3tables.amazonaws.com"), + ], + actions: ["kms:GenerateDataKey", "kms:Decrypt"], + resources: ["*"], + // TERRACONSTRUCTS DEVIATION: this repo's `iam.PolicyStatement` takes conditions as an + // array of `{ test, variable, values }` triples (rendered via the + // `aws_iam_policy_document` data source's `condition` blocks -- see + // `../../iam/policy-statement.ts`), unlike upstream's nested + // `{ StringLike: { key: value } }` object shape. + condition: [ + { + test: "StringLike", + variable: "kms:EncryptionContext:aws:s3:arn", + values: [ + `arn:${partition}:s3tables:${region}:${account}:bucket/${tableBucketName}/*`, + ], + }, + ], + }), + ); + } +} diff --git a/src/aws/storage/s3tables/table-policy.ts b/src/aws/storage/s3tables/table-policy.ts new file mode 100644 index 00000000..3e136714 --- /dev/null +++ b/src/aws/storage/s3tables/table-policy.ts @@ -0,0 +1,106 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-policy.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-s3tables-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. + +import { s3TablesTablePolicy } from "@cdktn/provider-aws"; +import { Construct } from "constructs"; +import type { ITable } from "./table"; +import { UnscopedValidationError } from "../../../errors"; +import { AwsConstructBase, AwsConstructProps } from "../../aws-construct"; +import * as iam from "../../iam"; + +/** + * Parameters for constructing a TablePolicy + */ +export interface TablePolicyProps extends AwsConstructProps { + /** + * The associated table + * + * TERRACONSTRUCTS DEVIATION: must have been constructed via `new Table(...)` in this stack (or + * otherwise carry a `namespace`). `aws_s3tables_table_policy` identifies its target table via + * `name` + `namespace` + `table_bucket_arn`, not a bare ARN — see the `ITable.namespace` TODO in + * `./table.ts`. Tables imported via `Table.fromTableAttributes` don't carry a `namespace` and + * will fail validation below. + */ + readonly table: ITable; + /** + * The policy document for the table's resource policy + * @default undefined An empty iam.PolicyDocument will be initialized + */ + readonly resourcePolicy?: iam.PolicyDocument; + + // NOTE: this repo's `iam.PolicyDocument` is itself a Construct (it renders synth-time to a + // Terraform `aws_iam_policy_document` data source, see `../../iam/policy-document.ts`), unlike + // upstream's plain data-object `iam.PolicyDocument` -- identical deviation to + // `TableBucketPolicyProps.resourcePolicy` in `./table-bucket-policy.ts`. + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.DESTROY`, + // applied via `_resource.applyRemovalPolicy`) is CloudFormation's DeletionPolicy concept. + // `core.RemovalPolicy` is not ported anywhere in this repo (see the identical omission on + // `TableProps`/`NamespaceProps` in `./table.ts`/`./namespace.ts` and on every other ported + // module, e.g. `../docdb/cluster.ts`). `aws_s3tables_table_policy` has no Terraform-native + // lifecycle replacement to offer here — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-policy.ts#L24-L28 + // readonly removalPolicy?: RemovalPolicy; +} + +/** + * A Policy for S3 Tables. + * + * You will almost never need to use this construct directly. + * Instead, Table.addToResourcePolicy can be used to add more policies to your table directly + * + * @resource aws_s3tables_table_policy + */ +export class TablePolicy extends AwsConstructBase { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.s3tables.TablePolicy"; + /** + * The IAM PolicyDocument containing permissions represented by this policy. + */ + public readonly document: iam.PolicyDocument; + /** + * @internal The underlying policy resource. + */ + private readonly _resource: s3TablesTablePolicy.S3TablesTablePolicy; + + public get outputs(): Record { + return { + tableBucketArn: this._resource.tableBucketArn, + }; + } + + constructor(scope: Construct, id: string, props: TablePolicyProps) { + // NOTE: `props.account`/`props.region` are intentionally NOT forwarded here -- identical + // rationale to `TableBucket`'s constructor in `./table-bucket.ts` (see the note on + // `TableBucketProps.region` there): forwarding them would give this construct's + // `env.account`/`env.region` a literal value that defeats the + // `iam.Grant.addToPrincipalOrResource()` same-account short-circuit (`../../iam/grant.ts`), + // spuriously attaching a resource policy to every same-account grant. + super(scope, id, { ...props, account: undefined, region: undefined }); + + if (!props.table.namespace) { + throw new UnscopedValidationError( + "TablePolicyProps: 'table' must have been created via `new Table(...)` (with a `namespace`) — imported tables (`Table.fromTableAttributes`) cannot be targeted by a TablePolicy because `aws_s3tables_table_policy` has no ARN-only addressing mode.", + ); + } + + // Use default policy if not provided with props + this.document = + props.resourcePolicy ?? new iam.PolicyDocument(this, "Policy"); + + this._resource = new s3TablesTablePolicy.S3TablesTablePolicy( + this, + "Resource", + { + name: props.table.tableName, + namespace: props.table.namespace.namespaceName, + tableBucketArn: props.table.namespace.tableBucket.tableBucketArn, + resourcePolicy: this.document.json, + }, + ); + } +} diff --git a/src/aws/storage/s3tables/table.ts b/src/aws/storage/s3tables/table.ts new file mode 100644 index 00000000..f1eaf499 --- /dev/null +++ b/src/aws/storage/s3tables/table.ts @@ -0,0 +1,745 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-s3tables-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. + +import { EOL } from "node:os"; +import { s3TablesTable, s3TablesTablePolicy } from "@cdktn/provider-aws"; +import { Token } from "cdktn"; +import { Construct } from "constructs"; +import type { INamespace } from "./namespace"; +import * as perms from "./permissions"; +import { AssumptionError, UnscopedValidationError } from "../../../errors"; +import { + AwsConstructBase, + AwsConstructProps, + IAwsConstruct, +} from "../../aws-construct"; +import * as iam from "../../iam"; + +/** + * Represents an S3 Table. + */ +export interface ITable extends IAwsConstruct { + /** + * The ARN of this table. + * @attribute + */ + readonly tableArn: string; + + /** + * The name of this table. + * @attribute + */ + readonly tableName: string; + + /** + * The accountId containing this table. + * @attribute + */ + readonly account?: string; + + /** + * The region containing this table. + * @attribute + */ + readonly region?: string; + + /** + * The namespace containing this table. + * + * TERRACONSTRUCTS DEVIATION: not present on upstream `ITable` (only exposed on the concrete + * `Table` class there). Unlike CloudFormation's `AWS::S3Tables::TablePolicy` (addressed via a + * bare `TableArn`), the Terraform `aws_s3tables_table_policy` resource has no ARN-only + * addressing mode — it identifies its target table via `name` + `namespace` + `table_bucket_arn` + * — so building the default (or a standalone `./table-policy.ts`) policy needs namespace access + * from `ITable`. Optional because imported tables (`fromTableAttributes`) don't carry it, which + * mirrors `autoCreatePolicy = false` for imports below: the default-policy branch that + * dereferences `namespace` is only ever reached when `autoCreatePolicy` is `true`, and only the + * concrete `Table` class ever sets that to `true`. + * https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3tables_table_policy + */ + readonly namespace?: INamespace; + + /** + * Adds a statement to the resource policy for a principal (i.e. + * account/role/service) to perform actions on this table. + * + * Note that the policy statement may or may not be added to the policy. + * For example, when an `ITable` is created from an existing table, + * it's not possible to tell whether the table already has a policy + * attached, let alone to re-use that policy to add more statements to it. + * So it's safest to do nothing in these cases. + * + * @param statement the policy statement to be added to the table's + * policy. + * @returns metadata about the execution of this method. If the policy + * was not added, the value of `statementAdded` will be `false`. You + * should always check this value to make sure that the operation was + * actually carried out. Otherwise, synthesis and deploy will terminate + * silently, which may be confusing. + */ + addToResourcePolicy( + statement: iam.PolicyStatement, + ): iam.AddToResourcePolicyResult; + + /** + * Grant read permissions for this table to an IAM principal (Role/Group/User). + * + * If the parent TableBucket of this table has encryption, + * you should grant kms:Decrypt permission to use this key to the same principal. + * + * @param identity The principal to allow read permissions to + */ + grantRead(identity: iam.IGrantable): iam.Grant; + + /** + * Grant write permissions for this table to an IAM principal (Role/Group/User). + * + * If the parent TableBucket of this table has encryption, + * you should grant kms:GenerateDataKey and kms:Decrypt permission + * to use this key to the same principal. + * + * @param identity The principal to allow write permissions to + */ + grantWrite(identity: iam.IGrantable): iam.Grant; + + /** + * Grant read and write permissions for this table to an IAM principal (Role/Group/User). + * + * If the parent TableBucket of this table has encryption, + * you should grant kms:GenerateDataKey and kms:Decrypt permission + * to use this key to the same principal. + * + * @param identity The principal to allow read and write permissions to + */ + grantReadWrite(identity: iam.IGrantable): iam.Grant; +} + +/** + * Base class for Table implementations. + */ +abstract class TableBase extends AwsConstructBase implements ITable { + public abstract readonly tableName: string; + public abstract readonly tableArn: string; + public abstract readonly namespace?: INamespace; + + /** + * The resource policy associated with this table. + * + * If `autoCreatePolicy` is true, a `S3TablesTablePolicy` will be created upon the + * first call to addToResourcePolicy(s). + */ + public abstract tablePolicy?: s3TablesTablePolicy.S3TablesTablePolicy; + + /** + * Indicates if a table resource policy should automatically created upon + * the first call to `addToResourcePolicy`. + */ + protected abstract autoCreatePolicy: boolean; + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Repo-wide construct-output convention (see + * e.g. `TableBucketBase.outputs` in `./table-bucket.ts`) — bare, bound-per-construct `outputs` + * for use with `registerOutputs`/the Grid. + */ + public get outputs(): Record { + return { + tableArn: this.tableArn, + tableName: this.tableName, + }; + } + + /** + * TERRACONSTRUCTS DEVIATION: not present upstream. Upstream's `CfnTablePolicy.resourcePolicy` + * accepts an `iam.PolicyDocument` object directly (CloudFormation resolves it at deploy-time), + * so statements added after the policy resource is created are picked up automatically. + * `aws_s3tables_table_policy`'s `resource_policy` is a plain Terraform string attribute instead, + * so this repo keeps a separate `iam.PolicyDocument` construct around (its `.json` getter is a + * Lazy-resolved token, so later `addStatements()` calls still flow through) — identical idiom to + * `resourcePolicy`/`DynamodbResourcePolicy` in `../table.ts`. + */ + private policyDocument?: iam.PolicyDocument; + + public addToResourcePolicy( + statement: iam.PolicyStatement, + ): iam.AddToResourcePolicyResult { + if (!this.tablePolicy && this.autoCreatePolicy) { + if (!this.namespace) { + // unreachable: only the concrete `Table` class sets `autoCreatePolicy = true`, and it + // always sets `namespace` in its constructor. + throw new AssumptionError( + "Cannot auto-create a table resource policy without a namespace", + ); + } + + this.policyDocument = new iam.PolicyDocument(this, "Policy", { + statement: [], + }); + this.tablePolicy = new s3TablesTablePolicy.S3TablesTablePolicy( + this, + "DefaultPolicy", + { + name: this.tableName, + namespace: this.namespace.namespaceName, + tableBucketArn: this.namespace.tableBucket.tableBucketArn, + resourcePolicy: this.policyDocument.json, + }, + ); + } + + if (this.tablePolicy && this.policyDocument) { + this.policyDocument.addStatements(statement); + return { statementAdded: true, policyDependable: this.tablePolicy }; + } + + return { statementAdded: false }; + } + + /** + * [disable-awslint:no-grants] + */ + public grantRead(identity: iam.IGrantable) { + return this.grant(identity, perms.TABLE_READ_ACCESS, this.tableArn); + } + + /** + * [disable-awslint:no-grants] + */ + public grantWrite(identity: iam.IGrantable) { + return this.grant(identity, perms.TABLE_WRITE_ACCESS, this.tableArn); + } + + /** + * [disable-awslint:no-grants] + */ + public grantReadWrite(identity: iam.IGrantable) { + return this.grant(identity, perms.TABLE_READ_WRITE_ACCESS, this.tableArn); + } + + /** + * Grants the given s3tables permissions to the provided principal + * @returns Grant object + */ + private grant( + grantee: iam.IGrantable, + tableActions: string[], + resourceArn: string, + ...otherResourceArns: (string | undefined)[] + ) { + const resources = [resourceArn, ...otherResourceArns].filter( + (arn) => arn != undefined, + ); + + const grant = iam.Grant.addToPrincipalOrResource({ + grantee, + actions: tableActions, + resourceArns: resources, + resource: this, + }); + + return grant; + } +} + +/** + * Properties for creating a new S3 Table. + */ +export interface TableProps extends AwsConstructProps { + /** + * Name of this table, unique within the namespace + * + * TERRACONSTRUCTS DEVIATION: kept required, matching upstream verbatim -- see the identical note + * on `TableBucketProps.tableBucketName` in `./table-bucket.ts` for the rationale (this repo's + * `uniqueResourceName` house-pattern default was deliberately not adopted here). + */ + readonly tableName: string; + /** + * The namespace under which this table is created + */ + readonly namespace: INamespace; + /** + * Format of this table. Currently, the only supported value is OpenTableFormat.ICEBERG. + */ + readonly openTableFormat: OpenTableFormat; + /** + * Settings governing the Compaction maintenance action. + * @default Amazon S3 selects the best compaction strategy based on your table sort order. + * @see https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-maintenance.html + */ + readonly compaction?: CompactionProperty; + /** + * Contains details about the metadata for an Iceberg table. + * @default table is created without any metadata + */ + readonly icebergMetadata?: IcebergMetadataProperty; + /** + * Contains details about the snapshot management settings for an Iceberg table. + * @default enabled: MinimumSnapshots is 1 by default and MaximumSnapshotAge is 120 hours by default. + */ + readonly snapshotManagement?: SnapshotManagementProperty; + + // TODO: omitted — upstream's `removalPolicy?: RemovalPolicy` (default `RemovalPolicy.RETAIN`, + // applied via `_resource.applyRemovalPolicy`) is CloudFormation's DeletionPolicy concept. + // `core.RemovalPolicy` is not ported anywhere in this repo (see the identical omission on + // `NamespaceProps`/`TablePolicyProps` in `./namespace.ts`/`./table-policy.ts` and on every other + // ported module, e.g. `../docdb/cluster.ts`). `aws_s3tables_table` has no Terraform-native + // lifecycle replacement to offer here (unlike e.g. `skipFinalSnapshot` on `aws_neptune_cluster`) — + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table.ts#L229-L234 + // readonly removalPolicy?: RemovalPolicy; + + /** + * If true, indicates that you don't want to specify a schema for the table. + * + * This property is mutually exclusive to 'IcebergMetadata'. + * + * TERRACONSTRUCTS DEVIATION: `aws_s3tables_table` has no dedicated `without_metadata` + * attribute (unlike `AWS::S3Tables::Table`'s `WithoutMetadata: "ENABLE"`). Simply omitting the + * `metadata` block from the Terraform resource (this repo's behavior when this is `true`, or + * when `icebergMetadata` is unset) is the provider-native equivalent — the mutual-exclusivity + * validation below is kept verbatim regardless. + * + * @default false + */ + readonly withoutMetadata?: boolean; +} + +/** + * Supported open table formats. + */ +export enum OpenTableFormat { + /** + * Apache Iceberg table format. + */ + ICEBERG = "ICEBERG", +} + +/** + * Settings governing the Compaction maintenance action. + * + * @default - No compaction settings + */ +export interface CompactionProperty { + /** + * Status of the compaction maintenance action. + */ + readonly status: Status; + /** + * Target file size in megabytes for compaction. + */ + readonly targetFileSizeMb: number; +} + +/** + * Status values for maintenance actions. + */ +export enum Status { + /** + * Enable the maintenance action. + */ + ENABLED = "enabled", + /** + * Disable the maintenance action. + */ + DISABLED = "disabled", +} + +// TODO: omitted — upstream also exports `IcebergTransform`, `SortDirection`, `NullOrder`, +// `IcebergPartitionField`, `IcebergPartitionSpec`, `IcebergSortField`, `IcebergSortOrder` and +// `TablePropertyEntry`, used to build `IcebergMetadataProperty.icebergPartitionSpec` / +// `.icebergSortOrder` / `.tableProperties` below. The Terraform `aws_s3tables_table` resource's +// `metadata` block exposes only `iceberg.schema.field.{name,required,type}` — it has no +// partition-spec, sort-order, or custom-table-properties equivalent at all (and no per-field `id`/ +// `fieldId` assignment either, see `SchemaFieldProperty`/`IcebergSchemaProperty` below). Per this +// repo's "no accepted-but-dropped props" convention, these types (and the metadata fields that +// would carry them) are commented out in place rather than silently ignored during synthesis — +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table.ts#L285-L509 +// https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3tables_table#schema +// +// export class IcebergTransform { ... } +// export enum SortDirection { ASC = 'asc', DESC = 'desc' } +// export enum NullOrder { NULLS_FIRST = 'nulls-first', NULLS_LAST = 'nulls-last' } +// export interface IcebergPartitionField { readonly sourceId: number; readonly transform: IcebergTransform; readonly name: string; readonly fieldId?: number; } +// export interface IcebergPartitionSpec { readonly fields: IcebergPartitionField[]; readonly specId?: number; } +// export interface IcebergSortField { readonly sourceId: number; readonly transform: IcebergTransform; readonly direction: SortDirection; readonly nullOrder: NullOrder; } +// export interface IcebergSortOrder { readonly fields: IcebergSortField[]; readonly orderId?: number; } +// export interface TablePropertyEntry { readonly key: string; readonly value: string; } + +/** + * Contains details about the metadata for an Iceberg table. + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3tables-table-icebergmetadata.html + */ +export interface IcebergMetadataProperty { + /** + * Contains details about the schema for an Iceberg table. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-s3tables-table-icebergmetadata.html#cfn-s3tables-table-icebergmetadata-icebergschema + */ + readonly icebergSchema: IcebergSchemaProperty; + + // TODO: omitted — `icebergPartitionSpec` / `icebergSortOrder` / `tableProperties`. See the + // `IcebergTransform` TODO block above for the full explanation of the Terraform provider gap. + // readonly icebergPartitionSpec?: IcebergPartitionSpec; + // readonly icebergSortOrder?: IcebergSortOrder; + // readonly tableProperties?: TablePropertyEntry[]; +} + +/** + * Contains details about the schema for an Iceberg table. + */ +export interface IcebergSchemaProperty { + /** + * Contains details about the schema for an Iceberg table. + */ + readonly schemaFieldList: SchemaFieldProperty[]; +} + +/** + * Contains details about a schema field. + */ +export interface SchemaFieldProperty { + // TODO: omitted — upstream's `id` (auto-assigned by S3 Tables when unset). The Terraform + // `aws_s3tables_table` resource's `metadata.iceberg.schema.field` block has no `id` attribute to + // set it through — see the `IcebergTransform` TODO block above. + // readonly id?: number; + + /** + * The name of the field. + */ + readonly name: string; + + /** + * A Boolean value that specifies whether values are required for each row in this field. + * + * By default, this is `false` and null values are allowed in the field. If this is `true`, the field does not allow null values. + * + * @default false + */ + readonly required?: boolean; + + /** + * The field type. + * + * S3 Tables supports all Apache Iceberg primitive types. For more information, see the [Apache Iceberg documentation](https://iceberg.apache.org/spec/#primitive-types). + */ + readonly type: string; +} + +/** + * Contains details about the snapshot management settings for an Iceberg table. + * + * A snapshot is expired when it exceeds MinSnapshotsToKeep and MaxSnapshotAgeHours. + * + * @default - No snapshot management settings + */ +export interface SnapshotManagementProperty { + /** + * The maximum age of a snapshot before it can be expired. + * + * @default - No maximum age + */ + readonly maxSnapshotAgeHours?: number; + + /** + * The minimum number of snapshots to keep. + * + * @default - No minimum number + */ + readonly minSnapshotsToKeep?: number; + + /** + * Indicates whether the SnapshotManagement maintenance action is enabled. + * + * @default - Not specified + */ + readonly status?: Status; +} + +/** + * A reference to a table outside this stack + * + * The tableName and tableArn can be provided explicitly. + */ +export interface TableAttributes { + /** + * Name of this table + */ + readonly tableName: string; + + /** + * The table's ARN. + */ + readonly tableArn: string; +} + +/** + * An S3 Table with helpers. + * + * @resource aws_s3tables_table + */ +export class Table extends TableBase { + /** Uniquely identifies this class. */ + public static readonly PROPERTY_INJECTION_ID: string = + "terraconstructs.aws.storage.s3tables.Table"; + + /** + * Defines a Table construct that represents an external table. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param attrs A `TableAttributes` object containing the table name and ARN. + */ + public static fromTableAttributes( + scope: Construct, + id: string, + attrs: TableAttributes, + ): ITable { + const tableArn = attrs.tableArn; + Table.validateTableName(attrs.tableName); + + class Import extends TableBase { + public readonly tableName = attrs.tableName; + public readonly tableArn = tableArn; + public readonly namespace?: INamespace = undefined; + public tablePolicy?: s3TablesTablePolicy.S3TablesTablePolicy; + protected autoCreatePolicy: boolean = false; + } + + return new Import(scope, id, { + environmentFromArn: tableArn, + }); + } + + /** + * See https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-buckets-naming.html + * @param tableName Name of the table + * @throws UnscopedValidationError if any naming errors are detected + */ + public static validateTableName(tableName: string) { + if (tableName == undefined || Token.isUnresolved(tableName)) { + // the name is a late-bound value, not a defined string, so skip validation + return; + } + + const errors: string[] = []; + + // Length validation + if (tableName.length < 1 || tableName.length > 255) { + errors.push( + "Table name must be at least 1 and no more than 255 characters", + ); + } + + // Character set validation + const illegalCharsetRegEx = /[^a-z0-9_]/; + const allowedEdgeCharsetRegEx = /[a-z0-9]/; + + const illegalCharMatch = tableName.match(illegalCharsetRegEx); + if (illegalCharMatch) { + errors.push( + "Table name must only contain lowercase characters, numbers, and underscores (_)" + + ` (offset: ${illegalCharMatch.index})`, + ); + } + + // Edge character validation + if (!allowedEdgeCharsetRegEx.test(tableName.charAt(0))) { + errors.push( + "Table name must start with a lowercase letter or number (offset: 0)", + ); + } + if (!allowedEdgeCharsetRegEx.test(tableName.charAt(tableName.length - 1))) { + errors.push( + `Table name must end with a lowercase letter or number (offset: ${ + tableName.length - 1 + })`, + ); + } + + if (errors.length > 0) { + throw new UnscopedValidationError( + `Invalid S3 table name (value: ${tableName})${EOL}${errors.join(EOL)}`, + ); + } + } + + /** + * The unique Amazon Resource Name (arn) of this table + */ + public readonly tableArn: string; + + /** + * The underlying CfnTable L1 resource + * @internal + */ + private readonly _resource: s3TablesTable.S3TablesTable; + + /** + * The name of this table + */ + public readonly tableName: string; + + /** + * The namespace containing this table + * + * TERRACONSTRUCTS DEVIATION: not present upstream (only exposed on `ITable`/`TableBase` there, + * matching this port). Declared `?: INamespace` here too (rather than the always-defined + * `INamespace` its constructor actually assigns) to satisfy jsii's language-compatibility rule + * that an implementing class may not turn an interface member required when the interface + * (`ITable.namespace` above) declares it optional (JSII5009) -- concrete `Table` instances always + * have this set; only `fromTableAttributes()`-imported tables leave it `undefined` (see + * `TableBase.namespace` / `ITable.namespace` docs above for why). + */ + public readonly namespace?: INamespace; + + /** + * The resource policy for this table. + */ + public tablePolicy?: s3TablesTablePolicy.S3TablesTablePolicy; + + protected autoCreatePolicy: boolean = true; + + constructor(scope: Construct, id: string, props: TableProps) { + // NOTE: `props.account`/`props.region` are intentionally NOT forwarded here -- identical + // rationale to `TableBucket`'s constructor in `./table-bucket.ts` (see the note on + // `TableBucketProps.region` there): forwarding them would give this construct's + // `env.account`/`env.region` a literal value that defeats the + // `iam.Grant.addToPrincipalOrResource()` same-account short-circuit (`../../iam/grant.ts`), + // spuriously attaching a resource policy to every same-account grant. + super(scope, id, { ...props, account: undefined, region: undefined }); + + if (props.withoutMetadata && props.icebergMetadata) { + throw new UnscopedValidationError( + "TableProps: 'withoutMetadata' and 'icebergMetadata' are mutually exclusive. Specify only one.", + ); + } + + Table.validateTableName(props.tableName); + + this._resource = new s3TablesTable.S3TablesTable(this, "Resource", { + name: props.tableName, + format: props.openTableFormat, + tableBucketArn: props.namespace.tableBucket.tableBucketArn, + namespace: props.namespace.namespaceName, + maintenanceConfiguration: this.buildMaintenanceConfiguration( + props.compaction, + props.snapshotManagement, + ), + metadata: this.buildMetadata(props.icebergMetadata), + }); + + this.namespace = props.namespace; + this.tableName = props.tableName; + this.tableArn = this._resource.arn; + // Non-null assertion: `this.namespace` was just unconditionally assigned above from + // `props.namespace` (required on `TableProps`) -- only the `fromTableAttributes()` import path + // (which never reaches this constructor) leaves it `undefined`. + this.node.addDependency(this.namespace!); + } + + /** + * Builds the Terraform maintenance_configuration block from the CDK compaction/snapshot + * management props. + * + * TERRACONSTRUCTS DEVIATION: upstream sets `compaction`/`snapshotManagement` as top-level + * `CfnTable` properties. `aws_s3tables_table` nests both one level deeper, under + * `maintenance_configuration.iceberg_compaction`/`.iceberg_snapshot_management`, with the + * numeric knobs further nested under a `settings` block — + * https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3tables_table#maintenance_configuration + * + * TERRACONSTRUCTS DEVIATION: not present upstream (CFN tolerates a partial/omitted + * `MaintenanceConfiguration`). `aws_s3tables_table.maintenance_configuration` is a Terraform + * *object*-typed attribute whose schema requires ALL members + * (`iceberg_compaction`/`iceberg_snapshot_management`) to be present -- unlike a Terraform + * *block*, an object type has no concept of an "unset" member; omitting one fails + * `terraform validate` with `Inappropriate value for attribute "maintenance_configuration": + * attribute "iceberg_snapshot_management" is required` (and symmetrically for + * `iceberg_compaction`), verified against the real provider schema + * (`terraform providers schema -json`, hashicorp/aws 6.55.0). This mirrors the all-members rule + * already applied to `encryption_configuration`/`maintenance_configuration` in + * `./table-bucket.ts` -- so both members are always rendered here too, never omitted. + * + * Only a literal `null` passed for an entire nested-object member (as opposed to a real object + * with explicit scalar leaves) throws at construction time -- the generated L1's `set + * internalValue` (`@cdktn/provider-aws` 24.8.0, AWS provider 6.52.0) unconditionally does + * `Object.keys(value).length` on any non-`undefined`, non-`IResolvable` value + * (`node_modules/@cdktn/provider-aws/lib/s3tables-table/index.js`, both the outer + * `S3TablesTableMaintenanceConfigurationOutputReference` and the + * `...IcebergCompactionOutputReference`/`...IcebergSnapshotManagementOutputReference` wrappers). + * And `null`-valued LEAVES, while valid to `terraform validate`, fail at APPLY: S3 Tables + * auto-populates server-side defaults for every unset maintenance value, so the provider's + * read-back contradicts the planned nulls ("Provider produced inconsistent result after + * apply", live-confirmed). Unset leaves are therefore pinned to AWS's documented defaults -- + * see the note inside the method body. + */ + private buildMaintenanceConfiguration( + compaction?: CompactionProperty, + snapshotManagement?: SnapshotManagementProperty, + ): s3TablesTable.S3TablesTableMaintenanceConfiguration | undefined { + if (!compaction && !snapshotManagement) { + return undefined; + } + + // TERRACONSTRUCTS DEVIATION (live-integ finding, 11th live-only defect class): the provider's + // object-typed `maintenance_configuration` requires ALL members, but null-filled leaves fail + // `terraform apply` with "Provider produced inconsistent result after apply" — S3 Tables + // AUTO-POPULATES server-side defaults for every unset maintenance value and returns them on + // read (live-confirmed: snapshot_management came back status="enabled", + // min_snapshots_to_keep=1, max_snapshot_age_hours=120 against planned nulls). Every unset leaf + // is therefore pinned to AWS's documented server-side default so the planned value matches the + // read-back — + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-maintenance.html + return { + icebergCompaction: { + status: compaction?.status ?? Status.ENABLED, + settings: { + targetFileSizeMb: compaction?.targetFileSizeMb ?? 512, + }, + }, + icebergSnapshotManagement: { + status: snapshotManagement?.status ?? Status.ENABLED, + settings: { + maxSnapshotAgeHours: snapshotManagement?.maxSnapshotAgeHours ?? 120, + minSnapshotsToKeep: snapshotManagement?.minSnapshotsToKeep ?? 1, + }, + }, + }; + } + + /** + * Builds the Terraform `metadata` block from the CDK Iceberg metadata model. + * + * TERRACONSTRUCTS DEVIATION: see the `SchemaFieldProperty`/`IcebergMetadataProperty` TODOs + * above — only `iceberg.schema.field.{name,required,type}` is representable; `metadata`, + * `iceberg`, and `schema` are themselves single-item Terraform list blocks — + * https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3tables_table#metadata + */ + private buildMetadata( + metadata?: IcebergMetadataProperty, + ): s3TablesTable.S3TablesTableMetadata[] | undefined { + if (!metadata) { + return undefined; + } + + return [ + { + iceberg: [ + { + schema: [ + { + field: metadata.icebergSchema.schemaFieldList.map((field) => ({ + name: field.name, + type: field.type, + ...(field.required !== undefined && { + required: field.required, + }), + })), + }, + ], + }, + ], + }, + ]; + } +} diff --git a/src/aws/storage/s3tables/util.ts b/src/aws/storage/s3tables/util.ts new file mode 100644 index 00000000..7114e1af --- /dev/null +++ b/src/aws/storage/s3tables/util.ts @@ -0,0 +1,127 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/util.ts +// +// TODO(alpha-tracker): ported from @aws-cdk/aws-s3tables-alpha@2.263.0-alpha.0 (stability: +// experimental). Re-diff against upstream on every reference-tag bump — alpha surfaces churn +// without deprecation cycles. + +import { IConstruct } from "constructs"; +import type { TableBucketAttributes } from "./table-bucket"; +import { UnscopedValidationError } from "../../../errors"; +import { ArnFormat } from "../../arn"; +import { AwsStack } from "../../aws-stack"; + +export const S3_TABLES_SERVICE = "s3tables"; + +export function parseTableBucketArn( + construct: IConstruct, + props: TableBucketAttributes, +): string { + // if we have an explicit table bucket ARN, use it. + if (props.tableBucketArn) { + return props.tableBucketArn; + } + + if (props.tableBucketName) { + return AwsStack.ofAwsConstruct(construct).formatArn({ + region: props.region, + account: props.account, + service: S3_TABLES_SERVICE, + resource: "bucket", + resourceName: props.tableBucketName, + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }); + } + + throw new UnscopedValidationError( + "Cannot determine bucket ARN. At least `tableBucketArn` is needed", + ); +} + +export function parseTableBucketName( + construct: IConstruct, + props: TableBucketAttributes, +): string { + // if we have an explicit bucket name, use it. + if (props.tableBucketName) { + return props.tableBucketName; + } + + // extract table bucket name from bucket arn + if (props.tableBucketArn) { + const bucketNameFromArn = AwsStack.ofAwsConstruct(construct).splitArn( + props.tableBucketArn, + ArnFormat.SLASH_RESOURCE_NAME, + ).resourceName; + if (bucketNameFromArn) { + return bucketNameFromArn; + } + } + + throw new UnscopedValidationError( + "tableBucketName is required and could not be inferred from context", + ); +} + +export function parseTableBucketRegion( + construct: IConstruct, + props: TableBucketAttributes, +): string | undefined { + // if we have an explicit bucket region, use it. + if (props.region) { + return props.region; + } + + // extract table bucket region from bucket arn + if (props.tableBucketArn) { + const regionFromArn = AwsStack.ofAwsConstruct(construct).splitArn( + props.tableBucketArn, + ArnFormat.SLASH_RESOURCE_NAME, + ).region; + if (regionFromArn) { + return regionFromArn; + } + } + + // Region is optional, can be inferred later + return undefined; +} + +export function parseTableBucketAccount( + construct: IConstruct, + props: TableBucketAttributes, +): string | undefined { + // if we have an explicit bucket account, use it. + if (props.account) { + return props.account; + } + + // extract table bucket account from bucket arn + if (props.tableBucketArn) { + const accountFromArn = AwsStack.ofAwsConstruct(construct).splitArn( + props.tableBucketArn, + ArnFormat.SLASH_RESOURCE_NAME, + ).account; + if (accountFromArn) { + return accountFromArn; + } + } + + // Account is optional, can be inferred later + return undefined; +} + +/** + * @returns populated attributes from given scope and attributes + * @throws UnscopedValidationError if any of the required attributes are missing + */ +export function validateTableBucketAttributes( + construct: IConstruct, + props: TableBucketAttributes, +) { + return { + tableBucketName: parseTableBucketName(construct, props), + account: parseTableBucketAccount(construct, props), + region: parseTableBucketRegion(construct, props), + tableBucketArn: parseTableBucketArn(construct, props), + }; +} diff --git a/test/aws/storage/s3tables/namespace.test.ts b/test/aws/storage/s3tables/namespace.test.ts new file mode 100644 index 00000000..2969ca9b --- /dev/null +++ b/test/aws/storage/s3tables/namespace.test.ts @@ -0,0 +1,240 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/namespace.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note -- see the identical notes in +// `../../../../src/aws/storage/s3tables/namespace.ts`. + +import { s3TablesNamespace } from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as s3tables from "../../../../src/aws/storage/s3tables"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +let stack: AwsStack; + +beforeEach(() => { + stack = testStack(); +}); + +describe("Namespace", () => { + describe("created with default properties", () => { + let namespace: s3tables.Namespace; + let tableBucket: s3tables.TableBucket; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, "test-bucket", { + tableBucketName: "test-bucket", + }); + namespace = new s3tables.Namespace(stack, "ExampleNamespace", { + namespaceName: "test_namespace", + tableBucket, + }); + }); + + test("creates a S3TablesNamespace resource", () => { + namespace; + new Template(stack).resourceCountIs( + s3TablesNamespace.S3TablesNamespace, + 1, + ); + }); + + test("with tableBucketArn property", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + s3TablesNamespace.S3TablesNamespace, + { + namespace: "test_namespace", + table_bucket_arn: stack.resolve(tableBucket.tableBucketArn), + }, + ); + }); + }); + + describe("namespace name validation", () => { + let tableBucket: s3tables.TableBucket; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, "test-bucket", { + tableBucketName: "test-bucket", + }); + }); + + describe("valid names", () => { + test("accepts lowercase letters and numbers", () => { + expect( + () => + new s3tables.Namespace(stack, "test1", { + namespaceName: "abc123", + tableBucket, + }), + ).not.toThrow(); + }); + + test("accepts underscores", () => { + expect( + () => + new s3tables.Namespace(stack, "test2", { + namespaceName: "test_namespace", + tableBucket, + }), + ).not.toThrow(); + }); + + test("accepts single character", () => { + expect( + () => + new s3tables.Namespace(stack, "test3", { + namespaceName: "a", + tableBucket, + }), + ).not.toThrow(); + }); + + test("accepts 255 character name", () => { + const longName = "a".repeat(255); + expect( + () => + new s3tables.Namespace(stack, "test4", { + namespaceName: longName, + tableBucket, + }), + ).not.toThrow(); + }); + }); + + describe("invalid names", () => { + test("rejects empty string", () => { + expect( + () => + new s3tables.Namespace(stack, "test1", { + namespaceName: "", + tableBucket, + }), + ).toThrow( + "Namespace name must be at least 1 and no more than 255 characters", + ); + }); + + test("rejects names longer than 255 characters", () => { + const longName = "a".repeat(256); + expect( + () => + new s3tables.Namespace(stack, "test2", { + namespaceName: longName, + tableBucket, + }), + ).toThrow( + "Namespace name must be at least 1 and no more than 255 characters", + ); + }); + + test("rejects uppercase letters", () => { + expect( + () => + new s3tables.Namespace(stack, "test3", { + namespaceName: "TestNamespace", + tableBucket, + }), + ).toThrow( + "Namespace name must only contain lowercase characters, numbers, and underscores (_)", + ); + }); + + test("rejects special characters", () => { + expect( + () => + new s3tables.Namespace(stack, "test4", { + namespaceName: "test-namespace", + tableBucket, + }), + ).toThrow( + "Namespace name must only contain lowercase characters, numbers, and underscores (_)", + ); + }); + + test("rejects starting with underscore", () => { + expect( + () => + new s3tables.Namespace(stack, "test5", { + namespaceName: "_test", + tableBucket, + }), + ).toThrow( + "Namespace name must start with a lowercase letter or number", + ); + }); + + test("rejects ending with underscore", () => { + expect( + () => + new s3tables.Namespace(stack, "test6", { + namespaceName: "test_", + tableBucket, + }), + ).toThrow("Namespace name must end with a lowercase letter or number"); + }); + + test("rejects names starting with aws", () => { + expect( + () => + new s3tables.Namespace(stack, "test7", { + namespaceName: "awstest", + tableBucket, + }), + ).toThrow("Namespace name must not start with reserved prefix 'aws'"); + }); + }); + }); + + describe("import existing namespace with attributes", () => { + let tableBucket: s3tables.TableBucket; + let importedNamespace: s3tables.INamespace; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, "ImportBucket", { + tableBucketName: "import-bucket", + }); + importedNamespace = s3tables.Namespace.fromNamespaceAttributes( + stack, + "ImportedNamespace", + { + namespaceName: "imported_namespace", + tableBucket, + }, + ); + }); + + test("has the same name as it was imported with", () => { + expect(importedNamespace.namespaceName).toBe("imported_namespace"); + }); + + test("has the same table bucket as it was imported with", () => { + expect(importedNamespace.tableBucket).toBe(tableBucket); + }); + + test("creates resource with correct construct id", () => { + expect(importedNamespace.node.id).toBe("ImportedNamespace"); + }); + }); +}); diff --git a/test/aws/storage/s3tables/table-bucket-encryption.test.ts b/test/aws/storage/s3tables/table-bucket-encryption.test.ts new file mode 100644 index 00000000..852e8b5f --- /dev/null +++ b/test/aws/storage/s3tables/table-bucket-encryption.test.ts @@ -0,0 +1,483 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-encryption.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note. + +import { + dataAwsIamPolicyDocument, + kmsKey, + s3TablesTableBucket, + s3TablesTableBucketPolicy, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as encryption from "../../../../src/aws/encryption"; +import * as iam from "../../../../src/aws/iam"; +import * as s3tables from "../../../../src/aws/storage/s3tables"; +import * as perms from "../../../../src/aws/storage/s3tables/permissions"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +const TABLE_BUCKET_NAME = "example-table-bucket"; +const TABLE_UUID = "example-table-uuid"; +const EXISTING_ROLE_ARN = "arn:aws:iam::123456789012:role/existing-role"; + +// Upstream `RESOURCES_WITH_TABLE_ARN`/`RESOURCES_WITH_WILDCARD` +// (getResourcesWithTablesArn(TABLE_UUID) / getResourcesWithTablesArn('*')), adapted to this +// repo's `aws_iam_policy_document` rendering: `TableBucketBase.getTableArn()` +// (`../../../../src/aws/storage/s3tables/table-bucket.ts`) builds +// `${tableBucketArn}/table/${tableId}` by plain string interpolation rather than upstream's +// `Fn::Join` CFN intrinsic. +const getResourcesWithTableArn = ( + stack: AwsStack, + bucket: s3tables.TableBucket, + tableId: string, +) => [ + stack.resolve(bucket.tableBucketArn), + `${stack.resolve(bucket.tableBucketArn)}/table/${tableId}`, +]; + +let stack: AwsStack; +let tableBucket: s3tables.TableBucket; +let role: iam.Role; +let importedRole: iam.IRole; +let user: iam.User; +let userKey: encryption.IKey; + +beforeEach(() => { + stack = testStack(); + role = new iam.Role(stack, "TestRole", { + assumedBy: new iam.ServicePrincipal("sample"), + }); + user = new iam.User(stack, "TestUser"); + importedRole = iam.Role.fromRoleArn(stack, "ImportedRole", EXISTING_ROLE_ARN); +}); + +describe("TableBucket with encryption", () => { + /** + * Templatizes grant tests across different test suites. + * + * @param withKMS whether to test for KMS policies + */ + const grantTests = ({ withKMS }: { withKMS: boolean }) => { + enum GrantType { + READ = "read", + WRITE = "write", + READ_WRITE = "read & write", + } + + const grantPermissions = ( + bucket: s3tables.TableBucket, + grantType: GrantType, + principal: iam.IGrantable, + tableId: string, + ) => { + switch (grantType) { + case GrantType.READ: + bucket.grantRead(principal, tableId); + return; + case GrantType.WRITE: + bucket.grantWrite(principal, tableId); + return; + case GrantType.READ_WRITE: + bucket.grantReadWrite(principal, tableId); + } + }; + + interface TestCase { + category: string; + grantType: GrantType; + actions: string[]; + keyActions: string[]; + } + + const testCases: TestCase[] = [ + { + category: "grantRead", + grantType: GrantType.READ, + actions: perms.TABLE_BUCKET_READ_ACCESS, + keyActions: perms.KEY_READ_ACCESS, + }, + { + category: "grantWrite", + grantType: GrantType.WRITE, + actions: perms.TABLE_BUCKET_WRITE_ACCESS, + keyActions: perms.KEY_WRITE_ACCESS, + }, + { + category: "grantReadWrite", + grantType: GrantType.READ_WRITE, + actions: perms.TABLE_BUCKET_READ_WRITE_ACCESS, + keyActions: perms.KEY_READ_WRITE_ACCESS, + }, + ]; + + testCases.forEach(({ category, grantType, actions, keyActions }) => { + describe(category, () => { + const verifyKeyPolicies = () => { + if (withKMS) { + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: expect.arrayContaining([ + expect.objectContaining({ + actions: keyActions, + effect: "Allow", + resources: ["*"], + }), + ]), + }, + ); + } + }; + + it(`provides ${grantType} permissions to the bucket ${withKMS && "and key"}`, () => { + grantPermissions( + tableBucket, + grantType, + new iam.ServicePrincipal("s3.amazonaws.com"), + "*", + ); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions, + effect: "Allow", + resources: getResourcesWithTableArn(stack, tableBucket, "*"), + }), + ], + }, + ); + verifyKeyPolicies(); + }); + + // Upstream `provides ${grantType} permissions for a specific table ${withKMS && 'and + // key'}` -- unlike `../table-bucket-grants.test.ts`'s same-named test (a literal upstream + // duplicate of its "to the bucket" sibling), this one differs meaningfully: it grants + // against a concrete `TABLE_UUID` tableId rather than the wildcard `"*"` above, so it's the + // only place in this suite that pins the per-table ARN suffix + // (`TableBucketBase.getTableArn()`) alongside KMS-key grants. See + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-encryption.test.ts + it(`provides ${grantType} permissions for a specific table ${withKMS && "and key"}`, () => { + grantPermissions( + tableBucket, + grantType, + new iam.ServicePrincipal("s3.amazonaws.com"), + TABLE_UUID, + ); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions, + effect: "Allow", + resources: getResourcesWithTableArn( + stack, + tableBucket, + TABLE_UUID, + ), + }), + ], + }, + ); + verifyKeyPolicies(); + }); + + it(`creates ${grantType} IAM policies for a role ${withKMS && "and key"}`, () => { + grantPermissions(tableBucket, grantType, role, TABLE_UUID); + const t = new Template(stack); + // TERRACONSTRUCTS DEVIATION: upstream renders the identity policy and the KMS key grant + // as separate `AWS::IAM::Policy` statements (CFN groups all inline-policy statements for + // a principal under one resource anyway). This repo's `Grant.addToPrincipalOrResource` + // (`../../iam/grant.ts`) instead accumulates BOTH the table-bucket actions and (when + // `withKMS`) the KMS key actions onto the SAME `aws_iam_policy_document` backing the + // role's single default inline policy -- both grant calls target the same + // `role.addToPrincipalPolicy` sink. + const expectedStatements: any[] = [ + expect.objectContaining({ + actions, + effect: "Allow", + resources: getResourcesWithTableArn( + stack, + tableBucket, + TABLE_UUID, + ), + }), + ]; + if (withKMS) { + expectedStatements.push( + expect.objectContaining({ actions: keyActions, effect: "Allow" }), + ); + } + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: expectedStatements, + }, + ); + t.resourceCountIs( + s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, + 0, + ); + }); + + it(`creates ${grantType} IAM policies for a user ${withKMS && "and key"}`, () => { + grantPermissions(tableBucket, grantType, user, TABLE_UUID); + const t = new Template(stack); + t.resourceCountIs( + s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, + 0, + ); + }); + + it(`creates ${grantType} IAM policies for an imported role ${withKMS && "and key"}`, () => { + grantPermissions(tableBucket, grantType, importedRole, TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions, + effect: "Allow", + principals: [ + { + type: "AWS", + identifiers: [EXISTING_ROLE_ARN], + }, + ], + resources: getResourcesWithTableArn( + stack, + tableBucket, + TABLE_UUID, + ), + }), + ], + }, + ); + }); + }); + }); + }; + + describe("with encryptionType undefined and encryptionKey provided", () => { + beforeEach(() => { + userKey = new encryption.Key(stack, "ExampleKey", {}); + tableBucket = new s3tables.TableBucket(stack, "ExampleTableBucket", { + account: "0123456789012", + region: "us-west-2", + tableBucketName: TABLE_BUCKET_NAME, + encryptionKey: userKey, + }); + }); + + it("creates a S3TablesTableBucket resource", () => { + new Template(stack).resourceCountIs( + s3TablesTableBucket.S3TablesTableBucket, + 1, + ); + }); + + it("has encryption configuration", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + s3TablesTableBucket.S3TablesTableBucket, + { + name: TABLE_BUCKET_NAME, + encryption_configuration: { + kms_key_arn: stack.resolve(userKey.keyArn), + sse_algorithm: "aws:kms", + }, + }, + ); + }); + + grantTests({ withKMS: true }); + }); + + describe("with encryptionType KMS and no encryptionKey provided", () => { + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, "ExampleTableBucket", { + account: "0123456789012", + region: "us-west-2", + tableBucketName: TABLE_BUCKET_NAME, + encryption: s3tables.TableBucketEncryption.KMS, + }); + }); + + it("creates a S3TablesTableBucket resource", () => { + new Template(stack).resourceCountIs( + s3TablesTableBucket.S3TablesTableBucket, + 1, + ); + }); + + it("creates a KmsKey resource", () => { + new Template(stack).resourceCountIs(kmsKey.KmsKey, 1); + }); + + it("has encryption configuration", () => { + const t = new Template(stack); + // NOTE: `kms_key_arn` also present (auto-created key) but intentionally not asserted here -- + // `expect.objectContaining` is required at this nesting level because the underlying jest + // matcher (`jestPassEvaluation` in `cdktn/lib/testing/adapters/jest.js`) only partial-matches + // the TOP-level properties object; nested plain objects require an exact key-set match. + t.expect.toHaveResourceWithProperties( + s3TablesTableBucket.S3TablesTableBucket, + { + name: TABLE_BUCKET_NAME, + encryption_configuration: expect.objectContaining({ + sse_algorithm: "aws:kms", + }), + }, + ); + }); + + it("key allowlists S3Tables maintenance SP", () => { + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: expect.arrayContaining([ + expect.objectContaining({ + sid: "AllowS3TablesMaintenanceAccess", + actions: ["kms:GenerateDataKey", "kms:Decrypt"], + effect: "Allow", + resources: ["*"], + // NOTE: `allowTablesMaintenanceAccessToKey()` uses `this.stack.region`/`.account` + // (matches upstream's own `this.stack.region`/`.account` verbatim), NOT the table + // bucket's own `env` -- so this resolves against the stack's default env, not the + // `account`/`region` passed to `TableBucketProps` above. + condition: [ + { + test: "StringLike", + variable: "kms:EncryptionContext:aws:s3:arn", + values: [ + `arn:\${data.aws_partition.Partitition.partition}:s3tables:us-east-1:\${data.aws_caller_identity.CallerIdentity.account_id}:bucket/${TABLE_BUCKET_NAME}/*`, + ], + }, + ], + }), + ]), + }, + ); + }); + + grantTests({ withKMS: true }); + }); + + describe("with encryptionType KMS and user-defined encryptionKey", () => { + beforeEach(() => { + userKey = new encryption.Key(stack, "ExampleKey", {}); + tableBucket = new s3tables.TableBucket(stack, "ExampleTableBucket", { + account: "0123456789012", + region: "us-west-2", + tableBucketName: TABLE_BUCKET_NAME, + encryption: s3tables.TableBucketEncryption.KMS, + encryptionKey: userKey, + }); + }); + + it("creates a S3TablesTableBucket resource", () => { + new Template(stack).resourceCountIs( + s3TablesTableBucket.S3TablesTableBucket, + 1, + ); + }); + + it("has encryption configuration", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + s3TablesTableBucket.S3TablesTableBucket, + { + name: TABLE_BUCKET_NAME, + encryption_configuration: { + kms_key_arn: stack.resolve(userKey.keyArn), + sse_algorithm: "aws:kms", + }, + }, + ); + }); + + grantTests({ withKMS: true }); + }); + + describe("with encryptionType S3_MANAGED and no encryptionKey provided", () => { + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, "ExampleTableBucket", { + account: "0123456789012", + region: "us-west-2", + tableBucketName: TABLE_BUCKET_NAME, + encryption: s3tables.TableBucketEncryption.S3_MANAGED, + }); + }); + + it("creates a S3TablesTableBucket resource", () => { + new Template(stack).resourceCountIs( + s3TablesTableBucket.S3TablesTableBucket, + 1, + ); + }); + + it("has encryption configuration", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + s3TablesTableBucket.S3TablesTableBucket, + { + name: TABLE_BUCKET_NAME, + encryption_configuration: { + sse_algorithm: "AES256", + // TERRACONSTRUCTS DEVIATION: `kms_key_arn` explicitly rendered as `null` (not simply + // omitted) -- `encryption_configuration` is a Terraform framework + // `schema.ObjectAttribute`, whose members must all be present in the rendered config; + // see the DEVIATION note on `TableBucket.parseEncryption()`'s S3_MANAGED branch. + kms_key_arn: null, + }, + }, + ); + }); + + grantTests({ withKMS: false }); + }); + + describe("with encryptionType S3_MANAGED and user-defined encryptionKey", () => { + it("throws a validation error in the constructor", () => { + userKey = new encryption.Key(stack, "ExampleKey", {}); + expect( + () => + new s3tables.TableBucket(stack, "ExampleTableBucket", { + account: "0123456789012", + region: "us-west-2", + tableBucketName: TABLE_BUCKET_NAME, + encryption: s3tables.TableBucketEncryption.S3_MANAGED, + encryptionKey: userKey, + }), + ).toThrow(); + }); + }); +}); diff --git a/test/aws/storage/s3tables/table-bucket-grants.test.ts b/test/aws/storage/s3tables/table-bucket-grants.test.ts new file mode 100644 index 00000000..266012e5 --- /dev/null +++ b/test/aws/storage/s3tables/table-bucket-grants.test.ts @@ -0,0 +1,409 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-grants.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note. + +import { + dataAwsIamPolicyDocument, + s3TablesTableBucketPolicy, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as iam from "../../../../src/aws/iam"; +import * as s3tables from "../../../../src/aws/storage/s3tables"; +import * as perms from "../../../../src/aws/storage/s3tables/permissions"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +const PRINCIPAL = "s3.amazonaws.com"; +const TABLE_UUID = "example-table-uuid"; +const EXISTING_ROLE_ARN = "arn:aws:iam::123456789012:role/existing-role"; + +// Upstream `RESOURCES_WITH_TABLE_ARN` (getResourcesWithTablesArn(TABLE_UUID)), adapted to this +// repo's `aws_iam_policy_document` rendering: `TableBucketBase.getTableArn()` +// (`../../../../src/aws/storage/s3tables/table-bucket.ts`) builds +// `${tableBucketArn}/table/${tableId}` by plain string interpolation rather than upstream's +// `Fn::Join` CFN intrinsic. +const getResourcesWithTableArn = ( + bucket: s3tables.TableBucket, + tableId: string, +) => [ + stack.resolve(bucket.tableBucketArn), + `${stack.resolve(bucket.tableBucketArn)}/table/${tableId}`, +]; + +let stack: AwsStack; +let tableBucket: s3tables.TableBucket; +let role: iam.Role; +let importedRole: iam.IRole; +let user: iam.User; + +beforeEach(() => { + stack = testStack(); + tableBucket = new s3tables.TableBucket(stack, "ExampleTableBucket", { + tableBucketName: "example-table-bucket", + }); + role = new iam.Role(stack, "TestRole", { + assumedBy: new iam.ServicePrincipal("sample"), + }); + user = new iam.User(stack, "TestUser"); + importedRole = iam.Role.fromRoleArn(stack, "ImportedRole", EXISTING_ROLE_ARN); +}); + +describe("Access grant methods", () => { + describe("grantRead", () => { + it("provides read and list permissions to the bucket", () => { + tableBucket.grantRead(new iam.ServicePrincipal(PRINCIPAL), TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_ACCESS, + effect: "Allow", + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + }); + + // Upstream `provides read and list permissions for a specific table` -- upstream ports this + // as a literal duplicate of the "to the bucket" test above (same `TABLE_UUID` tableId, same + // assertion), see + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-grants.test.ts + it("provides read and list permissions for a specific table", () => { + tableBucket.grantRead(new iam.ServicePrincipal(PRINCIPAL), TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_ACCESS, + effect: "Allow", + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + }); + + it("creates IAM policies for a role", () => { + tableBucket.grantRead(role, TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_ACCESS, + effect: "Allow", + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + t.resourceCountIs(s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, 0); + }); + + it("creates IAM policies for a user", () => { + tableBucket.grantRead(user, TABLE_UUID); + const t = new Template(stack); + t.resourceCountIs(s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, 0); + }); + + it("creates IAM policies for an imported role", () => { + tableBucket.grantRead(importedRole, TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_ACCESS, + effect: "Allow", + principals: [{ type: "AWS", identifiers: [EXISTING_ROLE_ARN] }], + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + }); + }); + + describe("grantWrite", () => { + it("provides write permissions to the bucket", () => { + tableBucket.grantWrite(new iam.ServicePrincipal(PRINCIPAL), TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_WRITE_ACCESS, + effect: "Allow", + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + }); + + // Upstream `provides write permissions for a specific table` -- literal duplicate of the "to + // the bucket" test above, see + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-grants.test.ts + it("provides write permissions for a specific table", () => { + tableBucket.grantWrite(new iam.ServicePrincipal(PRINCIPAL), TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_WRITE_ACCESS, + effect: "Allow", + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + }); + + it("creates IAM policies for a role", () => { + tableBucket.grantWrite(role, TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_WRITE_ACCESS, + effect: "Allow", + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + t.resourceCountIs(s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, 0); + }); + + it("creates IAM policies for a user", () => { + tableBucket.grantWrite(user, TABLE_UUID); + const t = new Template(stack); + t.resourceCountIs(s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, 0); + }); + + it("creates IAM policies for an imported role", () => { + tableBucket.grantWrite(importedRole, TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_WRITE_ACCESS, + effect: "Allow", + principals: [{ type: "AWS", identifiers: [EXISTING_ROLE_ARN] }], + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + }); + }); + + describe("grantReadWrite", () => { + it("provides read & write permissions to the bucket", () => { + tableBucket.grantReadWrite( + new iam.ServicePrincipal(PRINCIPAL), + TABLE_UUID, + ); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_WRITE_ACCESS, + effect: "Allow", + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + }); + + // Upstream `provides read & write permissions for a specific table` -- literal duplicate of + // the "to the bucket" test above, see + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-grants.test.ts + it("provides read & write permissions for a specific table", () => { + tableBucket.grantReadWrite( + new iam.ServicePrincipal(PRINCIPAL), + TABLE_UUID, + ); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_WRITE_ACCESS, + effect: "Allow", + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + }); + + it("creates IAM policies for a role", () => { + tableBucket.grantReadWrite(role, TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_WRITE_ACCESS, + effect: "Allow", + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + t.resourceCountIs(s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, 0); + }); + + it("creates IAM policies for a user", () => { + tableBucket.grantReadWrite(user, TABLE_UUID); + const t = new Template(stack); + t.resourceCountIs(s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, 0); + }); + + it("creates IAM policies for an imported role", () => { + tableBucket.grantReadWrite(importedRole, TABLE_UUID); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_WRITE_ACCESS, + effect: "Allow", + principals: [{ type: "AWS", identifiers: [EXISTING_ROLE_ARN] }], + resources: getResourcesWithTableArn(tableBucket, TABLE_UUID), + }), + ], + }, + ); + }); + }); + + describe("Multiple permissions on same bucket", () => { + it("permissions are isolated per principal", () => { + const accountPrincipal = new iam.AccountPrincipal("123456789012"); + const servicePrincipal = new iam.ServicePrincipal(PRINCIPAL); + tableBucket.grantRead(accountPrincipal, "table1"); + tableBucket.grantWrite(importedRole, "table2"); + tableBucket.grantRead(servicePrincipal, "table3"); + + const statement = new iam.PolicyStatement({ + effect: iam.Effect.DENY, + actions: ["s3tables:DeleteTable"], + principals: [new iam.ServicePrincipal("backup.amazonaws.com")], + resources: ["*"], + }); + tableBucket.addToResourcePolicy(statement); + + // tableBucketPolicy should have 4 different statements: one per grantRead/grantWrite call + // that targets a principal without a synchronously-known matching account (see the + // TERRACONSTRUCTS DEVIATION note on `../table-bucket-encryption.test.ts`'s + // `creates X IAM policies for a role` tests -- `AccountPrincipal`/`ServicePrincipal` always + // take this path, `importedRole` because it's genuinely cross-account here), plus the + // explicit `addToResourcePolicy` deny statement. + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_ACCESS, + effect: "Allow", + principals: [ + { + type: "AWS", + identifiers: [ + "arn:${data.aws_partition.Partitition.partition}:iam::123456789012:root", + ], + }, + ], + }), + expect.objectContaining({ + actions: perms.TABLE_BUCKET_WRITE_ACCESS, + effect: "Allow", + principals: [{ type: "AWS", identifiers: [EXISTING_ROLE_ARN] }], + }), + expect.objectContaining({ + actions: perms.TABLE_BUCKET_READ_ACCESS, + effect: "Allow", + principals: [ + { + type: "Service", + identifiers: [ + "${data.aws_service_principal.aws_svcp_default_region_s3.name}", + ], + }, + ], + }), + expect.objectContaining({ + actions: ["s3tables:DeleteTable"], + effect: "Deny", + principals: [ + { + type: "Service", + identifiers: [ + "${data.aws_service_principal.aws_svcp_default_region_backup.name}", + ], + }, + ], + }), + ], + }, + ); + + // Imported role's own identity policy should have write access to table2 + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + expect.objectContaining({ + actions: perms.TABLE_BUCKET_WRITE_ACCESS, + effect: "Allow", + }), + ], + }, + ); + }); + }); +}); diff --git a/test/aws/storage/s3tables/table-bucket-policy.test.ts b/test/aws/storage/s3tables/table-bucket-policy.test.ts new file mode 100644 index 00000000..ff77b499 --- /dev/null +++ b/test/aws/storage/s3tables/table-bucket-policy.test.ts @@ -0,0 +1,112 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket-policy.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note. + +import { s3TablesTableBucketPolicy } from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as iam from "../../../../src/aws/iam"; +import * as s3tables from "../../../../src/aws/storage/s3tables"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +let stack: AwsStack; + +beforeEach(() => { + stack = testStack(); +}); + +describe("TableBucketPolicy", () => { + describe("created with default properties", () => { + let tableBucketPolicy: s3tables.TableBucketPolicy; + let tableBucket: s3tables.TableBucket; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket(stack, "test-bucket", { + tableBucketName: "test-bucket", + }); + tableBucketPolicy = new s3tables.TableBucketPolicy( + stack, + "ExampleTableBucket", + { + tableBucket, + resourcePolicy: new iam.PolicyDocument(stack, "ExamplePolicyDoc", { + statement: [ + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ], + }), + }, + ); + }); + + test("creates a S3TablesTableBucketPolicy resource", () => { + tableBucketPolicy; + new Template(stack).resourceCountIs( + s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, + 1, + ); + }); + + test("with tableBucketArn property", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, + { + table_bucket_arn: stack.resolve(tableBucket.tableBucketArn), + }, + ); + }); + + test("bucket resourcePolicy contains statement", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, + { + resource_policy: stack.resolve(tableBucketPolicy.document.json), + }, + ); + }); + }); + + describe("created without an explicit resourcePolicy", () => { + // TERRACONSTRUCTS DEVIATION: not present upstream -- this repo's `iam.PolicyDocument` is a + // Construct (see the note on `TableBucketPolicyProps.resourcePolicy` in + // `../../../../src/aws/storage/s3tables/table-bucket-policy.ts`), so this exercises the + // default-construction branch of `TableBucketPolicy`'s constructor. + test("initializes an empty policy document", () => { + const tableBucket = new s3tables.TableBucket(stack, "test-bucket", { + tableBucketName: "test-bucket", + }); + const tableBucketPolicy = new s3tables.TableBucketPolicy( + stack, + "ExampleTableBucket", + { tableBucket }, + ); + + expect(tableBucketPolicy.document.isEmpty).toBe(true); + }); + }); +}); diff --git a/test/aws/storage/s3tables/table-bucket.test.ts b/test/aws/storage/s3tables/table-bucket.test.ts new file mode 100644 index 00000000..a748a447 --- /dev/null +++ b/test/aws/storage/s3tables/table-bucket.test.ts @@ -0,0 +1,490 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-bucket.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note. + +import { + dataAwsIamPolicyDocument, + s3TablesTableBucket, + s3TablesTableBucketPolicy, +} from "@cdktn/provider-aws"; +import { App, Testing, Token } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as iam from "../../../../src/aws/iam"; +import * as s3tables from "../../../../src/aws/storage/s3tables"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +let stack: AwsStack; + +beforeEach(() => { + stack = testStack(); +}); + +describe("TableBucket", () => { + describe("created with default properties", () => { + const DEFAULT_PROPS: s3tables.TableBucketProps = { + tableBucketName: "example-table-bucket", + }; + let tableBucket: s3tables.TableBucket; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket( + stack, + "ExampleTableBucket", + DEFAULT_PROPS, + ); + }); + + test("creates a S3TablesTableBucket resource", () => { + new Template(stack).resourceCountIs( + s3TablesTableBucket.S3TablesTableBucket, + 1, + ); + }); + + test("with tableBucketName property", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + s3TablesTableBucket.S3TablesTableBucket, + { + name: DEFAULT_PROPS.tableBucketName, + }, + ); + }); + + test("returns true from addToResourcePolicy", () => { + const result = tableBucket.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ); + + expect(result.statementAdded).toBe(true); + }); + }); + + describe("created with unreferenced file removal properties", () => { + const TABLE_BUCKET_PROPS: s3tables.TableBucketProps = { + account: "0123456789012", + region: "us-west-2", + tableBucketName: "example-table-bucket", + unreferencedFileRemoval: { + noncurrentDays: 10, + unreferencedDays: 10, + status: s3tables.UnreferencedFileRemovalStatus.ENABLED, + }, + }; + + beforeEach(() => { + new s3tables.TableBucket(stack, "ExampleTableBucket", TABLE_BUCKET_PROPS); + }); + + test("creates a S3TablesTableBucket resource", () => { + new Template(stack).resourceCountIs( + s3TablesTableBucket.S3TablesTableBucket, + 1, + ); + }); + + // TERRACONSTRUCTS DEVIATION: asserts the `maintenance_configuration.iceberg_unreferenced_file_removal` + // nested block (see the note on `TableBucket.renderMaintenanceConfiguration` in + // `../../../../src/aws/storage/s3tables/table-bucket.ts`) instead of upstream's top-level CFN + // `UnreferencedFileRemoval` property. Note the field-name difference: `noncurrentDays` (upstream) -> + // `non_current_days` (provider). Also note `status` is lower-cased at render time (provider/API + // enum is `enabled`/`disabled`, unlike CFN's `Enabled`/`Disabled` that the public + // `UnreferencedFileRemovalStatus` enum keeps for upstream fidelity). + test("has maintenance_configuration properties", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + s3TablesTableBucket.S3TablesTableBucket, + { + name: TABLE_BUCKET_PROPS.tableBucketName, + maintenance_configuration: { + iceberg_unreferenced_file_removal: { + status: "enabled", + settings: { + non_current_days: 10, + unreferenced_days: 10, + }, + }, + }, + }, + ); + }); + }); + + describe("defined with resource policy", () => { + const DEFAULT_PROPS: s3tables.TableBucketProps = { + tableBucketName: "example-table-bucket", + }; + let tableBucket: s3tables.TableBucket; + + beforeEach(() => { + tableBucket = new s3tables.TableBucket( + stack, + "ExampleTableBucket", + DEFAULT_PROPS, + ); + tableBucket.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ); + }); + + test("resourcePolicy contains statement", () => { + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["s3tables:*"], + effect: "Allow", + resources: ["*"], + }, + ], + }, + ); + }); + + test("calling multiple times appends statements", () => { + tableBucket.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3:*"], + effect: iam.Effect.DENY, + resources: ["*"], + }), + ); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["s3tables:*"], + effect: "Allow", + resources: ["*"], + }, + { + actions: ["s3:*"], + effect: "Deny", + resources: ["*"], + }, + ], + }, + ); + }); + }); + + describe("import existing table bucket with name", () => { + const BUCKET_PROPS = { + tableBucketName: "example-table-bucket", + }; + let tableBucket: s3tables.ITableBucket; + + beforeEach(() => { + tableBucket = s3tables.TableBucket.fromTableBucketAttributes( + stack, + "ExampleTableBucket", + BUCKET_PROPS, + ); + }); + + test("has the same name as it was imported with", () => { + expect(tableBucket.tableBucketName).toEqual(BUCKET_PROPS.tableBucketName); + tableBucket.grantRead(new iam.ServicePrincipal(""), "*"); + }); + + test("renders the correct ARN for Example Resource", () => { + const arn = stack.resolve(tableBucket.tableBucketArn); + expect(arn).toEqual( + `arn:\${data.aws_partition.Partitition.partition}:s3tables:us-east-1:\${data.aws_caller_identity.CallerIdentity.account_id}:bucket/${BUCKET_PROPS.tableBucketName}`, + ); + }); + + test("returns false from addToResourcePolicy", () => { + const result = tableBucket.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ); + + expect(result.statementAdded).toEqual(false); + }); + }); + + describe("import existing table bucket with arn", () => { + const BUCKET_NAME = "test-bucket"; + const ACCOUNT_ID = "123456789012"; + const REGION = "us-west-2"; + const BUCKET_ARN = `arn:aws:s3tables:${REGION}:${ACCOUNT_ID}:bucket/${BUCKET_NAME}`; + let tableBucket: s3tables.ITableBucket; + + beforeEach(() => { + tableBucket = s3tables.TableBucket.fromTableBucketArn( + stack, + "ExampleTableBucket", + BUCKET_ARN, + ); + }); + + test("has the same name as it was imported with", () => { + expect(tableBucket.tableBucketName).toEqual(BUCKET_NAME); + }); + + test("has the same region as it was imported with", () => { + expect(tableBucket.region).toEqual(REGION); + }); + + test("has the same account as it was imported with", () => { + expect(tableBucket.account).toEqual(ACCOUNT_ID); + }); + + test("returns false from addToResourcePolicy", () => { + const result = tableBucket.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ); + + expect(result.statementAdded).toEqual(false); + }); + }); + + describe("import existing table bucket with name, region and account", () => { + const BUCKET_PROPS = { + tableBucketName: "example-table-bucket", + region: "us-east-2", + account: "123456789012", + }; + let tableBucket: s3tables.ITableBucket; + + beforeEach(() => { + tableBucket = s3tables.TableBucket.fromTableBucketAttributes( + stack, + "ExampleTableBucket", + BUCKET_PROPS, + ); + }); + + test("has the same name as it was imported with", () => { + expect(tableBucket.tableBucketName).toEqual(BUCKET_PROPS.tableBucketName); + }); + + test("has the same account as it was imported with", () => { + expect(tableBucket.account).toEqual(BUCKET_PROPS.account); + }); + + test("has the same region as it was imported with", () => { + expect(tableBucket.region).toEqual(BUCKET_PROPS.region); + }); + + test("renders the correct ARN for Example Resource", () => { + const arn = stack.resolve(tableBucket.tableBucketArn); + expect(arn).toEqual( + `arn:\${data.aws_partition.Partitition.partition}:s3tables:${BUCKET_PROPS.region}:${BUCKET_PROPS.account}:bucket/${BUCKET_PROPS.tableBucketName}`, + ); + }); + + test("returns false from addToResourcePolicy", () => { + const result = tableBucket.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ); + + expect(result.statementAdded).toEqual(false); + new Template(stack).resourceCountIs( + s3TablesTableBucketPolicy.S3TablesTableBucketPolicy, + 0, + ); + }); + }); + + describe("validateUnreferencedFileRemoval", () => { + it("should not throw error when unreferencedFileRemovalProperty is undefined", () => { + expect(() => + s3tables.TableBucket.validateUnreferencedFileRemoval(undefined), + ).not.toThrow(); + }); + + it("should not throw error for valid property values", () => { + const validProperty = { + noncurrentDays: 1, + unreferencedDays: 1, + status: s3tables.UnreferencedFileRemovalStatus.ENABLED, + }; + expect(() => + s3tables.TableBucket.validateUnreferencedFileRemoval(validProperty), + ).not.toThrow(); + }); + + it("should throw error when noncurrentDays is less than 1", () => { + const invalidProperty = { + noncurrentDays: 0, + unreferencedDays: 1, + status: s3tables.UnreferencedFileRemovalStatus.ENABLED, + }; + expect(() => + s3tables.TableBucket.validateUnreferencedFileRemoval(invalidProperty), + ).toThrow(/noncurrentDays must be at least 1/); + }); + + it("should throw error when unreferencedDays is less than 1", () => { + const invalidProperty = { + noncurrentDays: 1, + unreferencedDays: 0, + status: s3tables.UnreferencedFileRemovalStatus.ENABLED, + }; + expect(() => + s3tables.TableBucket.validateUnreferencedFileRemoval(invalidProperty), + ).toThrow(/unreferencedDays must be at least 1/); + }); + + it("should not throw error when optional fields are undefined", () => { + const partialProperty = {}; + expect(() => + s3tables.TableBucket.validateUnreferencedFileRemoval(partialProperty), + ).not.toThrow(); + }); + + // TERRACONSTRUCTS DEVIATION: pins a known upstream bug, kept verbatim for byte-closeness (see + // the TODO(alpha-tracker) comment on `validateUnreferencedFileRemoval`'s `unreferencedDays` + // branch in `../../../../src/aws/storage/s3tables/table-bucket.ts`) -- + // https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/lib/table-bucket.ts#L571-L578 + // The whole-number check for `unreferencedDays` actually re-checks `noncurrentDays`, so a + // whole-number `unreferencedDays` supplied without `noncurrentDays` always (incorrectly) + // throws, since `Number.isInteger(undefined) === false`. + it("(pins upstream bug) throws when only unreferencedDays is provided, even though it's a whole number", () => { + const property = { + unreferencedDays: 5, + }; + expect(() => + s3tables.TableBucket.validateUnreferencedFileRemoval(property), + ).toThrow(/unreferencedDays must be a whole number/); + }); + }); + + describe("validateBucketName", () => { + it("should accept valid bucket names", () => { + const validNames = [ + "my-bucket-123", + "test-bucket", + "abc", + "a".repeat(63), + "123-bucket", + ]; + + validNames.forEach((name) => { + expect(() => + s3tables.TableBucket.validateTableBucketName(name), + ).not.toThrow(); + }); + }); + + it("should skip validation for unresolved tokens", () => { + const isUnresolved = Token.isUnresolved; + Token.isUnresolved = jest.fn().mockReturnValue(true); + expect(() => + s3tables.TableBucket.validateTableBucketName("unresolved"), + ).not.toThrow(); + // Cleanup + Token.isUnresolved = isUnresolved; + }); + + it("should skip validation for undefined name", () => { + expect(() => + s3tables.TableBucket.validateTableBucketName(undefined), + ).not.toThrow(); + }); + + it("should reject bucket names that are too short", () => { + expect(() => s3tables.TableBucket.validateTableBucketName("XX")).toThrow( + /Bucket name must be at least 3/, + ); + }); + + it("should reject bucket names that are too long", () => { + const longName = "a".repeat(64); + expect(() => + s3tables.TableBucket.validateTableBucketName(longName), + ).toThrow(/no more than 63 characters/); + }); + + it("should reject bucket names with illegal characters", () => { + const invalidNames = [ + "My-Bucket", // uppercase + "bucket!123", // special character + "bucket.123", // period + "bucket_123", // underscore + ]; + + invalidNames.forEach((name) => { + expect(() => + s3tables.TableBucket.validateTableBucketName(name), + ).toThrow( + /must only contain lowercase characters, numbers, and hyphens/, + ); + }); + }); + + it("should reject bucket names that start with invalid characters", () => { + const invalidNames = ["-bucket", ".bucket"]; + + invalidNames.forEach((name) => { + expect(() => + s3tables.TableBucket.validateTableBucketName(name), + ).toThrow(/must start with a lowercase letter or number/); + }); + }); + + it("should reject bucket names that end with invalid characters", () => { + const invalidNames = ["bucket-", "bucket."]; + + invalidNames.forEach((name) => { + expect(() => + s3tables.TableBucket.validateTableBucketName(name), + ).toThrow(/must end with a lowercase letter or number/); + }); + }); + + it("should include the invalid bucket name in the error message", () => { + const invalidName = "Invalid-Bucket!"; + expect(() => + s3tables.TableBucket.validateTableBucketName(invalidName), + ).toThrow(/Invalid-Bucket!/); + }); + + it("should handle empty bucket names", () => { + expect(() => s3tables.TableBucket.validateTableBucketName("")).toThrow( + /Bucket name must be at least 3/, + ); + }); + }); +}); diff --git a/test/aws/storage/s3tables/table-grants.test.ts b/test/aws/storage/s3tables/table-grants.test.ts new file mode 100644 index 00000000..6c3e57cd --- /dev/null +++ b/test/aws/storage/s3tables/table-grants.test.ts @@ -0,0 +1,361 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-grants.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note -- see the identical notes in +// `../../../../src/aws/storage/s3tables/table.ts`. + +import { + dataAwsIamPolicyDocument, + iamRolePolicy, + s3TablesTablePolicy, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as iam from "../../../../src/aws/iam"; +import * as s3tables from "../../../../src/aws/storage/s3tables"; +import * as perms from "../../../../src/aws/storage/s3tables/permissions"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +let stack: AwsStack; + +beforeEach(() => { + stack = testStack(); +}); + +describe("Table grant methods", () => { + const PRINCIPAL = "s3.amazonaws.com"; + const EXISTING_ROLE_ARN = "arn:aws:iam::123456789012:role/existing-role"; + + let table: s3tables.Table; + let role: iam.Role; + let importedRole: iam.IRole; + let user: iam.User; + + beforeEach(() => { + const tableBucket = new s3tables.TableBucket(stack, "TestTableBucket", { + tableBucketName: "test-table-bucket", + }); + const namespace = new s3tables.Namespace(stack, "TestNamespace", { + tableBucket, + namespaceName: "test_namespace", + }); + table = new s3tables.Table(stack, "TestTable", { + tableName: "test_table", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + }); + role = new iam.Role(stack, "TestRole", { + assumedBy: new iam.ServicePrincipal("sample"), + }); + user = new iam.User(stack, "TestUser"); + importedRole = iam.Role.fromRoleArn( + stack, + "ImportedRole", + EXISTING_ROLE_ARN, + ); + }); + + describe("grantRead", () => { + it("provides read permissions to the table", () => { + table.grantRead(new iam.ServicePrincipal(PRINCIPAL)); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_READ_ACCESS, + effect: "Allow", + principals: [ + { + type: "Service", + identifiers: [ + stack.resolve( + iam.ServicePrincipal.servicePrincipalName(PRINCIPAL), + ), + ], + }, + ], + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + }); + + it("creates IAM policies for a role", () => { + table.grantRead(role); + const t = new Template(stack); + t.resourceCountIs(iamRolePolicy.IamRolePolicy, 1); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_READ_ACCESS, + effect: "Allow", + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + t.resourceCountIs(s3TablesTablePolicy.S3TablesTablePolicy, 0); + }); + + it("creates IAM policies for a user", () => { + table.grantRead(user); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_READ_ACCESS, + effect: "Allow", + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + t.resourceCountIs(s3TablesTablePolicy.S3TablesTablePolicy, 0); + }); + + it("creates IAM policies for an imported role", () => { + table.grantRead(importedRole); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_READ_ACCESS, + effect: "Allow", + principals: [{ type: "AWS", identifiers: [EXISTING_ROLE_ARN] }], + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + }); + }); + + describe("grantWrite", () => { + it("provides write permissions to the table", () => { + table.grantWrite(new iam.ServicePrincipal(PRINCIPAL)); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_WRITE_ACCESS, + effect: "Allow", + principals: [ + { + type: "Service", + identifiers: [ + stack.resolve( + iam.ServicePrincipal.servicePrincipalName(PRINCIPAL), + ), + ], + }, + ], + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + }); + + it("creates IAM policies for a role", () => { + table.grantWrite(role); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_WRITE_ACCESS, + effect: "Allow", + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + t.resourceCountIs(s3TablesTablePolicy.S3TablesTablePolicy, 0); + }); + + it("creates IAM policies for a user", () => { + table.grantWrite(user); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_WRITE_ACCESS, + effect: "Allow", + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + t.resourceCountIs(s3TablesTablePolicy.S3TablesTablePolicy, 0); + }); + + it("creates IAM policies for an imported role", () => { + table.grantWrite(importedRole); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_WRITE_ACCESS, + effect: "Allow", + principals: [{ type: "AWS", identifiers: [EXISTING_ROLE_ARN] }], + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + }); + }); + + describe("grantReadWrite", () => { + it("provides read & write permissions to the table", () => { + table.grantReadWrite(new iam.ServicePrincipal(PRINCIPAL)); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_READ_WRITE_ACCESS, + effect: "Allow", + principals: [ + { + type: "Service", + identifiers: [ + stack.resolve( + iam.ServicePrincipal.servicePrincipalName(PRINCIPAL), + ), + ], + }, + ], + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + }); + + it("creates IAM policies for a role", () => { + table.grantReadWrite(role); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_READ_WRITE_ACCESS, + effect: "Allow", + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + t.resourceCountIs(s3TablesTablePolicy.S3TablesTablePolicy, 0); + }); + + it("creates IAM policies for a user", () => { + table.grantReadWrite(user); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_READ_WRITE_ACCESS, + effect: "Allow", + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + t.resourceCountIs(s3TablesTablePolicy.S3TablesTablePolicy, 0); + }); + + it("creates IAM policies for an imported role", () => { + table.grantReadWrite(importedRole); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: perms.TABLE_READ_WRITE_ACCESS, + effect: "Allow", + principals: [{ type: "AWS", identifiers: [EXISTING_ROLE_ARN] }], + resources: [stack.resolve(table.tableArn)], + }, + ], + }, + ); + }); + }); + + // Regression coverage: `TableProps.account` (inherited from `AwsConstructProps`, not present on + // upstream `TableProps`) must NOT be forwarded to `super()` in `Table`'s constructor -- see the + // note on the constructor in `../../../../src/aws/storage/s3tables/table.ts`. Forwarding it would + // give `table.env.account` a resolved literal value that diverges from a same-stack, no-explicit- + // account role's still-token `principalAccount`, defeating `Grant.addToPrincipalOrResource`'s + // same-account short-circuit (`../../../../src/aws/iam/grant.ts`) and spuriously attaching a + // resource policy for what is, at deploy time, actually the same account. + describe("grant same-account short-circuit with an explicit TableProps.account", () => { + it("does not create a resource policy for a same-stack role", () => { + const tableBucket = new s3tables.TableBucket(stack, "ExplicitAcctBkt", { + tableBucketName: "explicit-acct-bucket", + }); + const namespace = new s3tables.Namespace(stack, "ExplicitAcctNs", { + tableBucket, + namespaceName: "explicit_acct_namespace", + }); + const explicitAccountTable = new s3tables.Table( + stack, + "ExplicitAcctTable", + { + tableName: "explicit_acct_table", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + account: "111111111111", + }, + ); + + explicitAccountTable.grantRead(role); + + const t = new Template(stack); + t.resourceCountIs(s3TablesTablePolicy.S3TablesTablePolicy, 0); + }); + }); +}); diff --git a/test/aws/storage/s3tables/table-policy.test.ts b/test/aws/storage/s3tables/table-policy.test.ts new file mode 100644 index 00000000..8e473f65 --- /dev/null +++ b/test/aws/storage/s3tables/table-policy.test.ts @@ -0,0 +1,132 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table-policy.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note -- see the identical notes in +// `../../../../src/aws/storage/s3tables/table-policy.ts`. + +import { + dataAwsIamPolicyDocument, + s3TablesTablePolicy, +} from "@cdktn/provider-aws"; +import { App, Testing } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as iam from "../../../../src/aws/iam"; +import * as s3tables from "../../../../src/aws/storage/s3tables"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +let stack: AwsStack; + +beforeEach(() => { + stack = testStack(); +}); + +describe("TablePolicy", () => { + describe("created with default properties", () => { + let table: s3tables.Table; + + beforeEach(() => { + const tableBucket = new s3tables.TableBucket(stack, "test-bucket", { + tableBucketName: "test-bucket", + }); + const namespace = new s3tables.Namespace(stack, "test-namespace", { + tableBucket, + namespaceName: "test_namespace", + }); + table = new s3tables.Table(stack, "test-table", { + tableName: "test_table", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + }); + new s3tables.TablePolicy(stack, "ExampleTablePolicy", { + table, + resourcePolicy: (() => { + const doc = new iam.PolicyDocument(stack, "AccessPolicy"); + doc.addStatements( + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ); + return doc; + })(), + }); + }); + + test("creates a S3TablesTablePolicy resource", () => { + new Template(stack).resourceCountIs( + s3TablesTablePolicy.S3TablesTablePolicy, + 1, + ); + }); + + test("with name/namespace/tableBucketArn properties", () => { + // TERRACONSTRUCTS DEVIATION: `aws_s3tables_table_policy` identifies its target table via + // `name` + `namespace` + `table_bucket_arn` (not a bare `table_arn` like upstream's + // `AWS::S3Tables::TablePolicy.TableARN`) -- see the note on `ITable.namespace` in + // `../../../../src/aws/storage/s3tables/table.ts`. + const t = new Template(stack); + t.expect.toHaveResourceWithProperties( + s3TablesTablePolicy.S3TablesTablePolicy, + { + name: "test_table", + namespace: "test_namespace", + table_bucket_arn: stack.resolve( + table.namespace!.tableBucket.tableBucketArn, + ), + }, + ); + }); + + test("table resourcePolicy contains statement", () => { + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["s3tables:*"], + effect: "Allow", + resources: ["*"], + }, + ], + }, + ); + }); + }); + + describe("throws for imported tables", () => { + // TERRACONSTRUCTS DEVIATION: not present upstream. `aws_s3tables_table_policy` has no + // ARN-only addressing mode, so a `TablePolicy` cannot be built for a table imported via + // `Table.fromTableAttributes` (which carries no `namespace`) -- see `TablePolicyProps.table`. + test("rejects a table without a namespace", () => { + const table = s3tables.Table.fromTableAttributes(stack, "Imported", { + tableName: "example_table", + tableArn: "arn:aws:s3tables:us-west-2:123456789012:table/example_table", + }); + + expect( + () => new s3tables.TablePolicy(stack, "ExampleTablePolicy", { table }), + ).toThrow(/must have been created via `new Table\(\.\.\.\)`/); + }); + }); +}); diff --git a/test/aws/storage/s3tables/table.test.ts b/test/aws/storage/s3tables/table.test.ts new file mode 100644 index 00000000..d1778e6d --- /dev/null +++ b/test/aws/storage/s3tables/table.test.ts @@ -0,0 +1,540 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/table.test.ts +// +// Narrow behavioral gaps between this port and upstream (permanent capability differences, not +// pending work) are documented inline at each call site below with a TERRACONSTRUCTS +// DEVIATION/TODO note -- see the identical notes in +// `../../../../src/aws/storage/s3tables/table.ts`. +// +// SCOPE REDUCTION (this PR): a few upstream test suites exercise surfaces that have no +// Terraform-native equivalent (see the `IcebergTransform`/`SchemaFieldProperty` TODO block in +// `table.ts`) and are therefore omitted here rather than ported and commented out (mirrors +// `table-bucket.test.ts`'s identical omission of `RequestMetricsStatus`/tagging coverage): +// - `IcebergTransform`/`SortDirection`/`NullOrder` validation -- the types themselves are +// commented out in `table.ts`. +// - `icebergPartitionSpec`/`icebergSortOrder`/`tableProperties` (including the "partition spec +// and sort order" describe block, and the "tableProperties validation" duplicate-key checks) +// -- unsupported by `aws_s3tables_table`'s `metadata` block. +// - `removalPolicy`-driven `DeletionPolicy` assertions -- `core.RemovalPolicy` is not ported. +// - `ITaggableV2`/`TagManager` tagging coverage -- this repo has no CDK-style `TagManager`; any +// `aws_s3tables_table` is tagged automatically by the repo-wide `GridTags` Aspect instead. + +import { + dataAwsIamPolicyDocument, + s3TablesTable, + s3TablesTablePolicy, +} from "@cdktn/provider-aws"; +import { App, Testing, Token } from "cdktn"; +import "cdktn/lib/testing/adapters/jest"; +import { AwsStack } from "../../../../src/aws"; +import * as iam from "../../../../src/aws/iam"; +import * as s3tables from "../../../../src/aws/storage/s3tables"; +import { Template } from "../../../assertions"; + +const environmentName = "Test"; +const gridUUID = "a123e4567-e89b-12d3"; +const providerConfig = { region: "us-east-1" }; +// snapshot tests must not use the default local backend - its state file path +// is machine-dependent and would leak into the snapshot +const gridBackendConfig = { + address: "http://localhost:3000", +}; + +function testStack(app?: App, stackId?: string): AwsStack { + return new AwsStack(app ?? Testing.app(), stackId ?? "MyStack", { + environmentName, + gridUUID, + providerConfig, + gridBackendConfig, + }); +} + +let stack: AwsStack; +let namespace: s3tables.Namespace; + +beforeEach(() => { + stack = testStack(); + const tableBucket = new s3tables.TableBucket(stack, "TestTableBucket", { + tableBucketName: "test-table-bucket", + }); + namespace = new s3tables.Namespace(stack, "TestNamespace", { + namespaceName: "test_namespace", + tableBucket, + }); +}); + +describe("Table", () => { + describe("created with default properties", () => { + const DEFAULT_PROPS: Omit = { + tableName: "example_table", + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + }; + let table: s3tables.Table; + + beforeEach(() => { + table = new s3tables.Table(stack, "ExampleTable", { + ...DEFAULT_PROPS, + namespace, + }); + }); + + test("creates a S3TablesTable resource", () => { + new Template(stack).resourceCountIs(s3TablesTable.S3TablesTable, 1); + }); + + test("with name property", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(s3TablesTable.S3TablesTable, { + name: DEFAULT_PROPS.tableName, + }); + }); + + test("with format property", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(s3TablesTable.S3TablesTable, { + format: DEFAULT_PROPS.openTableFormat, + }); + }); + + test("returns true from addToResourcePolicy", () => { + const result = table.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ); + + expect(result.statementAdded).toBe(true); + expect(result.policyDependable).toBe(table.tablePolicy); + }); + }); + + describe("created with all properties", () => { + let table: s3tables.Table; + + beforeEach(() => { + table = new s3tables.Table(stack, "ExampleTable", { + tableName: "example_table", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + compaction: { + status: s3tables.Status.ENABLED, + targetFileSizeMb: 128, + }, + icebergMetadata: { + icebergSchema: { + schemaFieldList: [ + { + name: "id", + type: "int", + required: true, + }, + { + name: "name", + type: "string", + }, + ], + }, + }, + snapshotManagement: { + maxSnapshotAgeHours: 24, + minSnapshotsToKeep: 5, + status: s3tables.Status.ENABLED, + }, + }); + }); + + test("creates a S3TablesTable resource", () => { + table; + new Template(stack).resourceCountIs(s3TablesTable.S3TablesTable, 1); + }); + + test("has all properties", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(s3TablesTable.S3TablesTable, { + name: "example_table", + format: s3tables.OpenTableFormat.ICEBERG, + maintenance_configuration: { + iceberg_compaction: { + status: "enabled", + settings: { target_file_size_mb: 128 }, + }, + iceberg_snapshot_management: { + status: "enabled", + settings: { + max_snapshot_age_hours: 24, + min_snapshots_to_keep: 5, + }, + }, + }, + metadata: [ + { + iceberg: [ + { + schema: [ + { + field: [ + { name: "id", type: "int", required: true }, + { name: "name", type: "string" }, + ], + }, + ], + }, + ], + }, + ], + }); + }); + }); + + // Regression coverage for `buildMaintenanceConfiguration()`: `compaction`/`snapshotManagement` + // are independently optional (mirrors upstream `CfnTable`'s independent top-level properties), + // but `aws_s3tables_table.maintenance_configuration` is a Terraform object-typed attribute whose + // schema requires BOTH `iceberg_compaction`/`iceberg_snapshot_management` members to be present + // -- and S3 Tables auto-populates server-side defaults for every unset maintenance value + // (live-confirmed "Provider produced inconsistent result after apply" against planned nulls), so + // whichever of `compaction`/`snapshotManagement` was not supplied is rendered with AWS's + // documented defaults (compaction: enabled/512MB; snapshot management: enabled/120h/1) rather + // than omitted or null-filled. These two cases (only one of `compaction`/`snapshotManagement` + // supplied) exercise that defaults branch; the "created with all properties" suite above only + // exercises the neither-or-both paths. + describe("created with compaction only (no snapshotManagement)", () => { + let table: s3tables.Table; + + beforeEach(() => { + table = new s3tables.Table(stack, "ExampleTable", { + tableName: "example_table", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + compaction: { + status: s3tables.Status.ENABLED, + targetFileSizeMb: 128, + }, + }); + }); + + test("creates a S3TablesTable resource", () => { + new Template(stack).resourceCountIs(s3TablesTable.S3TablesTable, 1); + }); + + test("renders iceberg_compaction and an AWS-defaults iceberg_snapshot_management in maintenance_configuration", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(s3TablesTable.S3TablesTable, { + name: "example_table", + maintenance_configuration: { + iceberg_compaction: { + status: "enabled", + settings: { target_file_size_mb: 128 }, + }, + iceberg_snapshot_management: { + status: "enabled", + settings: { + max_snapshot_age_hours: 120, + min_snapshots_to_keep: 1, + }, + }, + }, + }); + }); + }); + + describe("created with snapshotManagement only (no compaction)", () => { + let table: s3tables.Table; + + beforeEach(() => { + table = new s3tables.Table(stack, "ExampleTable", { + tableName: "example_table", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + snapshotManagement: { + maxSnapshotAgeHours: 24, + minSnapshotsToKeep: 5, + status: s3tables.Status.ENABLED, + }, + }); + }); + + test("creates a S3TablesTable resource", () => { + new Template(stack).resourceCountIs(s3TablesTable.S3TablesTable, 1); + }); + + test("renders iceberg_snapshot_management and an AWS-defaults iceberg_compaction in maintenance_configuration", () => { + const t = new Template(stack); + t.expect.toHaveResourceWithProperties(s3TablesTable.S3TablesTable, { + name: "example_table", + maintenance_configuration: { + iceberg_compaction: { + status: "enabled", + settings: { target_file_size_mb: 512 }, + }, + iceberg_snapshot_management: { + status: "enabled", + settings: { + max_snapshot_age_hours: 24, + min_snapshots_to_keep: 5, + }, + }, + }, + }); + }); + }); + + describe("created with withoutMetadata property", () => { + let table: s3tables.Table; + + beforeEach(() => { + table = new s3tables.Table(stack, "ExampleTable", { + tableName: "example_table", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + withoutMetadata: true, + }); + }); + + // TERRACONSTRUCTS DEVIATION: `aws_s3tables_table` has no `without_metadata` attribute (unlike + // upstream's `WithoutMetadata: "Yes"` CFN property) -- see the note on + // `TableProps.withoutMetadata` in `../../../../src/aws/storage/s3tables/table.ts`. Omitting the + // `metadata` block entirely is the provider-native equivalent. + test("omits the metadata block", () => { + const t = new Template(stack); + const [tableResource] = t.resourceTypeArray( + s3TablesTable.S3TablesTable, + ) as { metadata?: unknown }[]; + table; + expect(tableResource.metadata).toBeUndefined(); + }); + }); + + describe("defined with resource policy", () => { + let table: s3tables.Table; + + beforeEach(() => { + table = new s3tables.Table(stack, "ExampleTable", { + tableName: "example_table", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + }); + table.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ); + }); + + test("resourcePolicy contains statement", () => { + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["s3tables:*"], + effect: "Allow", + resources: ["*"], + }, + ], + }, + ); + }); + + test("calling multiple times appends statements", () => { + table.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3:*"], + effect: iam.Effect.DENY, + resources: ["*"], + }), + ); + const t = new Template(stack); + t.expect.toHaveDataSourceWithProperties( + dataAwsIamPolicyDocument.DataAwsIamPolicyDocument, + { + statement: [ + { + actions: ["s3tables:*"], + effect: "Allow", + resources: ["*"], + }, + { + actions: ["s3:*"], + effect: "Deny", + resources: ["*"], + }, + ], + }, + ); + }); + }); + + describe("import existing table with attributes", () => { + const TABLE_ATTRS = { + tableName: "example_table", + tableArn: "arn:aws:s3tables:us-west-2:123456789012:table/example_table", + }; + let table: s3tables.ITable; + + beforeEach(() => { + table = s3tables.Table.fromTableAttributes( + stack, + "ImportedTable", + TABLE_ATTRS, + ); + }); + + test("has the same name as it was imported with", () => { + expect(table.tableName).toEqual(TABLE_ATTRS.tableName); + }); + + test("has the same ARN as it was imported with", () => { + expect(table.tableArn).toEqual(TABLE_ATTRS.tableArn); + }); + + test("validates table name during import", () => { + expect(() => { + s3tables.Table.fromTableAttributes(stack, "InvalidImport", { + tableName: "Invalid-Table", + tableArn: + "arn:aws:s3tables:us-west-2:123456789012:table/Invalid-Table", + }); + }).toThrow( + "Table name must only contain lowercase characters, numbers, and underscores (_)", + ); + }); + + test("creates resource with correct physical name", () => { + expect(table.node.id).toBe("ImportedTable"); + }); + + test("addToResourcePolicy does not add a policy", () => { + const result = table.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ["s3tables:*"], + resources: ["*"], + }), + ); + + expect(result.statementAdded).toEqual(false); + expect(result.policyDependable).toBeUndefined(); + new Template(stack).resourceCountIs( + s3TablesTablePolicy.S3TablesTablePolicy, + 0, + ); + }); + }); + + describe("validateTableName", () => { + it("should accept valid table names", () => { + const validNames = [ + "my_table_123", + "test_table", + "abc", + "a".repeat(255), + "123_table", + ]; + + validNames.forEach((name) => { + expect(() => s3tables.Table.validateTableName(name)).not.toThrow(); + }); + }); + + it("should skip validation for unresolved tokens", () => { + const isUnresolved = Token.isUnresolved; + Token.isUnresolved = jest.fn().mockReturnValue(true); + expect(() => + s3tables.Table.validateTableName("unresolved"), + ).not.toThrow(); + // Cleanup + Token.isUnresolved = isUnresolved; + }); + + it("should reject table names that are too short", () => { + expect(() => s3tables.Table.validateTableName("")).toThrow( + /Table name must be at least 1/, + ); + }); + + it("should reject table names that are too long", () => { + const longName = "a".repeat(256); + expect(() => s3tables.Table.validateTableName(longName)).toThrow( + /no more than 255 characters/, + ); + }); + + it("should reject table names with illegal characters", () => { + const invalidNames = [ + "My-Table", // uppercase + "table!123", // special character + "table-123", // hyphen + ]; + + invalidNames.forEach((name) => { + expect(() => s3tables.Table.validateTableName(name)).toThrow( + /must only contain lowercase characters, numbers, and underscores/, + ); + }); + }); + + it("should reject table names that start with invalid characters", () => { + const invalidNames = ["_table"]; + + invalidNames.forEach((name) => { + expect(() => s3tables.Table.validateTableName(name)).toThrow( + /must start with a lowercase letter or number/, + ); + }); + }); + + it("should reject table names that end with invalid characters", () => { + const invalidNames = ["table_"]; + + invalidNames.forEach((name) => { + expect(() => s3tables.Table.validateTableName(name)).toThrow( + /must end with a lowercase letter or number/, + ); + }); + }); + + it("should include the invalid table name in the error message", () => { + const invalidName = "Invalid-Table!"; + expect(() => s3tables.Table.validateTableName(invalidName)).toThrow( + /Invalid-Table!/, + ); + }); + }); + + describe("table name validation through Table creation", () => { + test("rejects table creation with invalid table name", () => { + expect(() => { + new s3tables.Table(stack, "TestTable", { + tableName: "Invalid-Table", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + }); + }).toThrow( + "Table name must only contain lowercase characters, numbers, and underscores (_)", + ); + }); + + test("rejects table creation with empty table name", () => { + expect(() => { + new s3tables.Table(stack, "TestTable", { + tableName: "", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + }); + }).toThrow( + "Table name must be at least 1 and no more than 255 characters", + ); + }); + + test("rejects table creation with table name starting with underscore", () => { + expect(() => { + new s3tables.Table(stack, "TestTable", { + tableName: "_invalid", + namespace, + openTableFormat: s3tables.OpenTableFormat.ICEBERG, + }); + }).toThrow("Table name must start with a lowercase letter or number"); + }); + }); +}); diff --git a/test/aws/storage/s3tables/test-utils.ts b/test/aws/storage/s3tables/test-utils.ts new file mode 100644 index 00000000..0f277f0c --- /dev/null +++ b/test/aws/storage/s3tables/test-utils.ts @@ -0,0 +1,8 @@ +// https://github.com/aws/aws-cdk/blob/v2.263.0/packages/@aws-cdk/aws-s3tables-alpha/test/test-utils.ts + +/** + * If the given array is 1-length, return the element in it. + * Useful for IAM policies/actions + */ +export const singletonOrArr = (array: string[]) => + array.length === 1 ? array[0] : array;