Skip to content

Commit 515aa4b

Browse files
authored
feat: delete_object and delete_objects (#175)
* feat: implement delete_object on S3EncryptionClient Implement delete_object per the spec requirement that DeleteObject MUST delete both the given object key and its associated instruction file. Accepts an optional InstructionFileSuffix kwarg (default ".instruction") mirroring get_object's per-request suffix pattern. * feat: implement delete_objects API Implement DeleteObjects on S3EncryptionClient per spec requirements: - DeleteObjects MUST delete each of the given objects - DeleteObjects MUST delete each of the corresponding instruction files using the default instruction file suffix Uses two separate delete_objects calls (objects, then instruction files) to preserve the S3 1,000-key limit for callers and keep the response clean. Follows the same pattern as the existing delete_object method. * Add integration tests for delete_objects API
1 parent 586ad4b commit 515aa4b

4 files changed

Lines changed: 509 additions & 0 deletions

File tree

src/s3_encryption/__init__.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,93 @@ def put_object(self, **kwargs):
395395
if hasattr(self._plugin._context, _CTX_ENCRYPTION_CONTEXT):
396396
delattr(self._plugin._context, _CTX_ENCRYPTION_CONTEXT)
397397

398+
##= specification/s3-encryption/client.md#required-api-operations
399+
##% - DeleteObject MUST be implemented by the S3EC.
400+
def delete_object(self, **kwargs):
401+
"""Delete an object and its associated instruction file from S3.
402+
403+
Args:
404+
**kwargs: Arguments to pass to the S3 client's delete_object method.
405+
Must include Bucket and Key parameters.
406+
May include InstructionFileSuffix to override the default
407+
".instruction" suffix for instruction file deletion.
408+
409+
Returns:
410+
The response from the S3 client's delete_object call for the object.
411+
412+
Raises:
413+
S3EncryptionClientError: If the delete operation fails.
414+
"""
415+
##= specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
416+
##= type=implementation
417+
##% The default Instruction File behavior uses the same S3 object key
418+
##% as its associated object suffixed with ".instruction".
419+
instruction_file_suffix = kwargs.pop("InstructionFileSuffix", ".instruction")
420+
421+
try:
422+
##= specification/s3-encryption/client.md#required-api-operations
423+
##= type=implementation
424+
##% - DeleteObject MUST delete the given object key.
425+
response = self.wrapped_s3_client.delete_object(**kwargs)
426+
427+
##= specification/s3-encryption/client.md#required-api-operations
428+
##= type=implementation
429+
##% - DeleteObject MUST delete the associated instruction file
430+
##% using the default instruction file suffix.
431+
instruction_key = kwargs["Key"] + instruction_file_suffix
432+
self.wrapped_s3_client.delete_object(Bucket=kwargs["Bucket"], Key=instruction_key)
433+
434+
return response
435+
except S3EncryptionClientError:
436+
raise
437+
except Exception as e:
438+
raise S3EncryptionClientError(f"Failed to delete object: {str(e)}") from e
439+
440+
##= specification/s3-encryption/client.md#required-api-operations
441+
##% - DeleteObjects MUST be implemented by the S3EC.
442+
def delete_objects(self, **kwargs):
443+
"""Delete multiple objects and their associated instruction files from S3.
444+
445+
2 requests are issued, one for the objects, and one for the instruction files.
446+
If either requests fail, the operation fails, and maybe tried again to clean up any missed files.
447+
448+
Args:
449+
**kwargs: Arguments to pass to the S3 client's delete_objects method.
450+
Must include Bucket and Delete (with Objects list) parameters.
451+
May include InstructionFileSuffix to override the default
452+
".instruction" suffix for instruction file deletion.
453+
454+
Returns:
455+
The response from the S3 client's delete_objects call for the objects.
456+
457+
Raises:
458+
S3EncryptionClientError: If either delete operations fails.
459+
"""
460+
instruction_file_suffix = kwargs.pop("InstructionFileSuffix", ".instruction")
461+
462+
try:
463+
##= specification/s3-encryption/client.md#required-api-operations
464+
##= type=implementation
465+
##% - DeleteObjects MUST delete each of the given objects.
466+
response = self.wrapped_s3_client.delete_objects(**kwargs)
467+
468+
##= specification/s3-encryption/client.md#required-api-operations
469+
##= type=implementation
470+
##% - DeleteObjects MUST delete each of the corresponding instruction files
471+
##% using the default instruction file suffix.
472+
instruction_objects = [
473+
{"Key": obj["Key"] + instruction_file_suffix} for obj in kwargs["Delete"]["Objects"]
474+
]
475+
self.wrapped_s3_client.delete_objects(
476+
Bucket=kwargs["Bucket"], Delete={"Objects": instruction_objects}
477+
)
478+
479+
return response
480+
except S3EncryptionClientError:
481+
raise
482+
except Exception as e:
483+
raise S3EncryptionClientError(f"Failed to delete objects: {str(e)}") from e
484+
398485
def get_object(self, **kwargs):
399486
"""Download and decrypt an object from S3.
400487
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Integration tests for S3EncryptionClient.delete_objects."""
4+
5+
import os
6+
from datetime import datetime
7+
8+
import boto3
9+
import pytest
10+
from botocore.exceptions import ClientError
11+
12+
from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig
13+
from s3_encryption.materials.kms_keyring import KmsKeyring
14+
from s3_encryption.materials.materials import AlgorithmSuite, CommitmentPolicy
15+
16+
bucket = os.environ.get("CI_S3_BUCKET", "s3ec-python-github-test-bucket")
17+
region = os.environ.get("CI_AWS_REGION", "us-west-2")
18+
kms_key_id = os.environ.get(
19+
"CI_KMS_KEY_ALIAS", "arn:aws:kms:us-west-2:370957321024:alias/S3EC-Python-Github-KMS-Key"
20+
)
21+
22+
ALGORITHM_CONFIGS = [
23+
pytest.param(
24+
AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
25+
CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT,
26+
id="AES_GCM",
27+
),
28+
pytest.param(
29+
AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
30+
CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT,
31+
id="KC_GCM",
32+
),
33+
]
34+
35+
36+
def _make_client(algorithm_suite, commitment_policy):
37+
kms_client = boto3.client("kms", region_name=region)
38+
keyring = KmsKeyring(kms_client, kms_key_id)
39+
wrapped_client = boto3.client("s3")
40+
config = S3EncryptionClientConfig(
41+
keyring,
42+
encryption_algorithm=algorithm_suite,
43+
commitment_policy=commitment_policy,
44+
)
45+
return S3EncryptionClient(wrapped_client, config)
46+
47+
48+
def _unique_key(prefix):
49+
return prefix + datetime.now().strftime("%Y-%m-%d-%H:%M:%S-%f")
50+
51+
52+
def _object_exists(key):
53+
"""Return True if the object exists in the test bucket."""
54+
s3 = boto3.client("s3")
55+
try:
56+
s3.head_object(Bucket=bucket, Key=key)
57+
return True
58+
except ClientError as e:
59+
if e.response["Error"]["Code"] == "404":
60+
return False
61+
raise
62+
63+
64+
##= specification/s3-encryption/client.md#required-api-operations
65+
##= type=test
66+
##% - DeleteObjects MUST delete each of the given objects.
67+
@pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS)
68+
def test_delete_objects_deletes_objects(algorithm_suite, commitment_policy):
69+
"""delete_objects removes the encrypted objects from S3."""
70+
s3ec = _make_client(algorithm_suite, commitment_policy)
71+
keys = [_unique_key("del-objs-"), _unique_key("del-objs-")]
72+
73+
for key in keys:
74+
s3ec.put_object(Bucket=bucket, Key=key, Body=b"data")
75+
76+
s3ec.delete_objects(
77+
Bucket=bucket,
78+
Delete={"Objects": [{"Key": k} for k in keys]},
79+
)
80+
81+
for key in keys:
82+
assert not _object_exists(key)
83+
84+
85+
##= specification/s3-encryption/client.md#required-api-operations
86+
##= type=test
87+
##% - DeleteObjects MUST delete each of the corresponding instruction files
88+
##% using the default instruction file suffix.
89+
@pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS)
90+
def test_delete_objects_deletes_instruction_files(algorithm_suite, commitment_policy):
91+
"""delete_objects also removes the .instruction files from S3."""
92+
s3ec = _make_client(algorithm_suite, commitment_policy)
93+
keys = [_unique_key("del-objs-instr-"), _unique_key("del-objs-instr-")]
94+
95+
# Put instruction-file-based objects by uploading instruction files manually
96+
plain_s3 = boto3.client("s3")
97+
for key in keys:
98+
s3ec.put_object(Bucket=bucket, Key=key, Body=b"data")
99+
# Also create a fake instruction file to verify it gets deleted
100+
plain_s3.put_object(Bucket=bucket, Key=key + ".instruction", Body=b"{}")
101+
102+
s3ec.delete_objects(
103+
Bucket=bucket,
104+
Delete={"Objects": [{"Key": k} for k in keys]},
105+
)
106+
107+
for key in keys:
108+
assert not _object_exists(key)
109+
assert not _object_exists(key + ".instruction")
110+
111+
112+
@pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS)
113+
def test_delete_objects_returns_response(algorithm_suite, commitment_policy):
114+
"""delete_objects returns the S3 response from the object deletion."""
115+
s3ec = _make_client(algorithm_suite, commitment_policy)
116+
key = _unique_key("del-objs-resp-")
117+
s3ec.put_object(Bucket=bucket, Key=key, Body=b"data")
118+
119+
response = s3ec.delete_objects(
120+
Bucket=bucket,
121+
Delete={"Objects": [{"Key": key}]},
122+
)
123+
124+
assert "Deleted" in response
125+
deleted_keys = [d["Key"] for d in response["Deleted"]]
126+
assert key in deleted_keys
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""Unit tests for S3EncryptionClient.delete_object."""
4+
5+
from unittest.mock import Mock, call
6+
7+
import pytest
8+
9+
from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig
10+
from s3_encryption.exceptions import S3EncryptionClientError
11+
from s3_encryption.materials.keyring import S3Keyring
12+
13+
14+
def _make_client():
15+
"""Create an S3EncryptionClient with a mocked wrapped S3 client."""
16+
mock_keyring = Mock(spec=S3Keyring)
17+
mock_s3 = Mock()
18+
mock_s3.meta.events = Mock()
19+
config = S3EncryptionClientConfig(keyring=mock_keyring)
20+
s3ec = S3EncryptionClient(wrapped_s3_client=mock_s3, config=config)
21+
return s3ec, mock_s3
22+
23+
24+
class TestDeleteObject:
25+
##= specification/s3-encryption/client.md#required-api-operations
26+
##= type=test
27+
##% - DeleteObject MUST delete the given object key.
28+
def test_deletes_object(self):
29+
"""delete_object forwards the call to the wrapped client."""
30+
s3ec, mock_s3 = _make_client()
31+
mock_s3.delete_object.return_value = {"DeleteMarker": True}
32+
33+
response = s3ec.delete_object(Bucket="bucket", Key="key")
34+
35+
assert response == {"DeleteMarker": True}
36+
assert mock_s3.delete_object.call_args_list[0] == call(Bucket="bucket", Key="key")
37+
38+
##= specification/s3-encryption/client.md#required-api-operations
39+
##= type=test
40+
##% - DeleteObject MUST delete the associated instruction file
41+
##% using the default instruction file suffix.
42+
def test_deletes_instruction_file(self):
43+
"""delete_object also deletes the instruction file with default suffix."""
44+
s3ec, mock_s3 = _make_client()
45+
46+
s3ec.delete_object(Bucket="bucket", Key="key")
47+
48+
assert mock_s3.delete_object.call_count == 2
49+
assert mock_s3.delete_object.call_args_list[1] == call(
50+
Bucket="bucket", Key="key.instruction"
51+
)
52+
53+
def test_returns_object_delete_response(self):
54+
"""delete_object returns the response from the object deletion, not the instruction file."""
55+
s3ec, mock_s3 = _make_client()
56+
object_response = {"DeleteMarker": True, "VersionId": "v1"}
57+
instruction_response = {"DeleteMarker": False, "VersionId": "v2"}
58+
mock_s3.delete_object.side_effect = [object_response, instruction_response]
59+
60+
response = s3ec.delete_object(Bucket="bucket", Key="key")
61+
62+
assert response == object_response
63+
64+
def test_wraps_unexpected_errors(self):
65+
"""delete_object wraps unexpected errors in S3EncryptionClientError."""
66+
s3ec, mock_s3 = _make_client()
67+
mock_s3.delete_object.side_effect = RuntimeError("network error")
68+
69+
with pytest.raises(S3EncryptionClientError, match="Failed to delete object"):
70+
s3ec.delete_object(Bucket="bucket", Key="key")
71+
72+
def test_reraises_s3_encryption_client_error(self):
73+
"""delete_object re-raises S3EncryptionClientError without wrapping."""
74+
s3ec, mock_s3 = _make_client()
75+
mock_s3.delete_object.side_effect = S3EncryptionClientError("original error")
76+
77+
with pytest.raises(S3EncryptionClientError, match="original error"):
78+
s3ec.delete_object(Bucket="bucket", Key="key")
79+
80+
def test_passes_extra_kwargs(self):
81+
"""delete_object forwards extra kwargs like VersionId to the wrapped client."""
82+
s3ec, mock_s3 = _make_client()
83+
84+
s3ec.delete_object(Bucket="bucket", Key="key", VersionId="abc123")
85+
86+
assert mock_s3.delete_object.call_args_list[0] == call(
87+
Bucket="bucket", Key="key", VersionId="abc123"
88+
)
89+
90+
def test_custom_instruction_file_suffix(self):
91+
"""delete_object uses a custom instruction file suffix when provided."""
92+
s3ec, mock_s3 = _make_client()
93+
94+
s3ec.delete_object(Bucket="bucket", Key="key", InstructionFileSuffix=".custom-suffix")
95+
96+
assert mock_s3.delete_object.call_count == 2
97+
assert mock_s3.delete_object.call_args_list[1] == call(
98+
Bucket="bucket", Key="key.custom-suffix"
99+
)
100+
101+
def test_instruction_file_suffix_not_forwarded_to_s3(self):
102+
"""InstructionFileSuffix is popped from kwargs and not sent to S3."""
103+
s3ec, mock_s3 = _make_client()
104+
105+
s3ec.delete_object(Bucket="bucket", Key="key", InstructionFileSuffix=".custom")
106+
107+
# First call (object delete) should not contain InstructionFileSuffix
108+
assert mock_s3.delete_object.call_args_list[0] == call(Bucket="bucket", Key="key")

0 commit comments

Comments
 (0)