diff --git a/handwritten/storage/src/bucket.ts b/handwritten/storage/src/bucket.ts index fde1a9bfd18..f57a213df95 100644 --- a/handwritten/storage/src/bucket.ts +++ b/handwritten/storage/src/bucket.ts @@ -297,6 +297,10 @@ export interface RestoreOptions { generation: string; projection?: 'full' | 'noAcl'; } +export interface EncryptionEnforcementConfig { + restrictionMode?: 'NotRestricted' | 'FullyRestricted'; + readonly effectiveTime?: string; +} export interface BucketMetadata extends BaseMetadata { acl?: AclMetadata[] | null; autoclass?: { @@ -316,6 +320,9 @@ export interface BucketMetadata extends BaseMetadata { defaultObjectAcl?: AclMetadata[]; encryption?: { defaultKmsKeyName?: string; + googleManagedEncryptionEnforcementConfig?: EncryptionEnforcementConfig; + customerManagedEncryptionEnforcementConfig?: EncryptionEnforcementConfig; + customerSuppliedEncryptionEnforcementConfig?: EncryptionEnforcementConfig; } | null; hierarchicalNamespace?: { enabled?: boolean; @@ -1193,6 +1200,25 @@ class Bucket extends ServiceObject { * }, function(err, apiResponse) {}); * * //- + * // Enforce CMEK-only encryption for new objects. + * // This blocks Google-Managed and Customer-Supplied keys. + * //- + * bucket.setMetadata({ + * encryption: { + * defaultKmsKeyName: 'projects/grape-spaceship-123/...', + * googleManagedEncryptionEnforcementConfig: { + * restrictionMode: 'FullyRestricted' + * }, + * customerSuppliedEncryptionEnforcementConfig: { + * restrictionMode: 'FullyRestricted' + * }, + * customerManagedEncryptionEnforcementConfig: { + * restrictionMode: 'NotRestricted' + * } + * } + * }, function(err, apiResponse) {}); + * + * //- * // Set the default event-based hold value for new objects in this * // bucket. * //- diff --git a/handwritten/storage/system-test/storage.ts b/handwritten/storage/system-test/storage.ts index 2f14fe01296..3edee247249 100644 --- a/handwritten/storage/system-test/storage.ts +++ b/handwritten/storage/system-test/storage.ts @@ -2992,6 +2992,89 @@ describe('storage', function () { `${metadata!.encryption!.defaultKmsKeyName}/cryptoKeyVersions/1`, ); }); + + describe('encryption enforcement', () => { + it('should enforce FullyRestricted CSEK policy', async () => { + await bucket.setMetadata({ + encryption: { + defaultKmsKeyName: kmsKeyName, + customerSuppliedEncryptionEnforcementConfig: { + restrictionMode: 'FullyRestricted', + }, + }, + }); + + await new Promise(res => + setTimeout(res, BUCKET_METADATA_UPDATE_WAIT_TIME) + ); + + const encryptionKey = crypto.randomBytes(32); + const file = bucket.file('csek-attempt', {encryptionKey}); + + await assert.rejects( + file.save(FILE_CONTENTS, {resumable: false}), + (err: ApiError) => { + const failureMessage = + "Requested encryption type for object is not compliant with the bucket's encryption enforcement configuration."; + assert.strictEqual(err.code, 412); + assert.ok(err.message.includes(failureMessage)); + return true; + } + ); + }); + + it('should allow uploads that comply with enforcement', async () => { + await bucket.setMetadata({ + encryption: { + googleManagedEncryptionEnforcementConfig: { + restrictionMode: 'NotRestricted', + }, + }, + }); + + const file = bucket.file('compliant-file'); + await file.save(FILE_CONTENTS); + + const [metadata] = await file.getMetadata(); + assert.ok(metadata.customerEncryption); + }); + + it('should retain defaultKmsKeyName when updating enforcement settings independently', async () => { + await bucket.setMetadata({ + encryption: { + defaultKmsKeyName: kmsKeyName, + }, + }); + + await new Promise(res => + setTimeout(res, BUCKET_METADATA_UPDATE_WAIT_TIME) + ); + + await bucket.setMetadata({ + encryption: { + googleManagedEncryptionEnforcementConfig: { + restrictionMode: 'FullyRestricted', + }, + }, + }); + + await new Promise(res => + setTimeout(res, BUCKET_METADATA_UPDATE_WAIT_TIME) + ); + + const [metadata] = await bucket.getMetadata(); + assert.strictEqual( + metadata.encryption?.defaultKmsKeyName, + kmsKeyName + ); + + assert.strictEqual( + metadata.encryption?.googleManagedEncryptionEnforcementConfig + ?.restrictionMode, + 'FullyRestricted' + ); + }); + }); }); }); diff --git a/handwritten/storage/test/bucket.ts b/handwritten/storage/test/bucket.ts index 5b49fa518d8..f59f41e21a1 100644 --- a/handwritten/storage/test/bucket.ts +++ b/handwritten/storage/test/bucket.ts @@ -3300,4 +3300,145 @@ describe('Bucket', () => { done(); }); }); + + describe('setMetadata', () => { + describe('encryption enforcement', () => { + it('should correctly format restrictionMode for all enforcement types', () => { + const effectiveTime = '2026-02-02T12:00:00Z'; + const encryptionMetadata = { + encryption: { + defaultKmsKeyName: 'kms-key-name', + googleManagedEncryptionEnforcementConfig: { + restrictionMode: 'FullyRestricted', + effectiveTime: effectiveTime, + }, + customerManagedEncryptionEnforcementConfig: { + restrictionMode: 'NotRestricted', + effectiveTime: effectiveTime, + }, + customerSuppliedEncryptionEnforcementConfig: { + restrictionMode: 'FullyRestricted', + effectiveTime: effectiveTime, + }, + }, + }; + + bucket.setMetadata = (metadata: BucketMetadata) => { + assert.strictEqual( + metadata.encryption?.defaultKmsKeyName, + encryptionMetadata.encryption.defaultKmsKeyName + ); + + assert.deepStrictEqual( + metadata.encryption?.googleManagedEncryptionEnforcementConfig, + {restrictionMode: 'FullyRestricted', effectiveTime: effectiveTime} + ); + + assert.deepStrictEqual( + metadata.encryption?.customerManagedEncryptionEnforcementConfig, + {restrictionMode: 'NotRestricted', effectiveTime: effectiveTime} + ); + + assert.deepStrictEqual( + metadata.encryption?.customerSuppliedEncryptionEnforcementConfig, + {restrictionMode: 'FullyRestricted', effectiveTime: effectiveTime} + ); + }; + bucket.setMetadata(encryptionMetadata, assert.ifError); + }); + + it('should preserve existing encryption fields during a partial update', done => { + bucket.metadata = { + encryption: { + defaultKmsKeyName: 'kms-key-name', + googleManagedEncryptionEnforcementConfig: { + restrictionMode: 'FullyRestricted', + }, + }, + }; + + const patch = { + encryption: { + customerSuppliedEncryptionEnforcementConfig: { + restrictionMode: 'FullyRestricted', + }, + }, + }; + + bucket.setMetadata = (metadata: BucketMetadata) => { + assert.strictEqual( + metadata.encryption?.customerSuppliedEncryptionEnforcementConfig + ?.restrictionMode, + 'FullyRestricted' + ); + done(); + }; + + bucket.setMetadata(patch, assert.ifError); + }); + + it('should reject or handle invalid restrictionMode values', done => { + const invalidMetadata = { + encryption: { + googleManagedEncryptionEnforcementConfig: { + restrictionMode: 'fully_restricted', + }, + }, + }; + + bucket.setMetadata = (metadata: BucketMetadata) => { + assert.strictEqual( + metadata.encryption?.googleManagedEncryptionEnforcementConfig + ?.restrictionMode, + 'fully_restricted' + ); + done(); + }; + + bucket.setMetadata(invalidMetadata, assert.ifError); + }); + + it('should not include enforcement configs that are not provided', done => { + const partialMetadata = { + encryption: { + defaultKmsKeyName: 'test-key', + googleManagedEncryptionEnforcementConfig: { + restrictionMode: 'FullyRestricted', + }, + }, + }; + + bucket.setMetadata = (metadata: BucketMetadata) => { + assert.ok(metadata.encryption?.defaultKmsKeyName); + assert.ok( + metadata.encryption?.googleManagedEncryptionEnforcementConfig + ); + assert.strictEqual( + metadata.encryption?.customerManagedEncryptionEnforcementConfig, + undefined + ); + assert.strictEqual( + metadata.encryption?.customerSuppliedEncryptionEnforcementConfig, + undefined + ); + done(); + }; + + bucket.setMetadata(partialMetadata, assert.ifError); + }); + + it('should allow nullifying encryption enforcement', done => { + const clearMetadata = { + encryption: null, + }; + + bucket.setMetadata = (metadata: BucketMetadata) => { + assert.strictEqual(metadata.encryption, null); + done(); + }; + + bucket.setMetadata(clearMetadata, assert.ifError); + }); + }); + }); });