Skip to content

Commit 210276b

Browse files
fix(#1537): allow s3 stores without static credentials (#1538)
s3 was the only storage protocol that mandated access_key/secret_key; gcs and azure already fall through to their default credential chains. Make the two optional so an ambient AWS identity (instance profile, IRSA, ECS task role, SSO) is used when no static keys are configured. - settings.py: drop access_key/secret_key from required_keys["s3"]. - storage.py::_validate_spec: drop them from required; reject exactly one of the pair (botocore would otherwise fail late with PartialCredentialsError). - storage.py::_create_filesystem: self.spec["access_key"] -> .get(...) or None so a missing OR empty-string credential is dropped and botocore resolves the chain (a forwarded "" is read as an explicit, invalid credential). Both-present behavior is unchanged; backward compatible. Adds unit tests for the ambient, both-present, empty-string, and partial-credential cases.
1 parent 8656f49 commit 210276b

4 files changed

Lines changed: 108 additions & 4 deletions

File tree

src/datajoint/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,7 +466,7 @@ def get_store_spec(self, store: str | None = None, *, use_filepath_default: bool
466466
# Define required and allowed keys by protocol
467467
required_keys: dict[str, tuple[str, ...]] = {
468468
"file": ("protocol", "location"),
469-
"s3": ("protocol", "endpoint", "bucket", "access_key", "secret_key", "location"),
469+
"s3": ("protocol", "endpoint", "bucket", "location"),
470470
"gcs": ("protocol", "bucket", "location"),
471471
"azure": ("protocol", "container", "location"),
472472
}

src/datajoint/storage.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,10 +340,21 @@ def _validate_spec(self):
340340
if location and not Path(location).is_dir():
341341
raise FileNotFoundError(f"Inaccessible local directory {location}")
342342
elif self.protocol == "s3":
343-
required = ["endpoint", "bucket", "access_key", "secret_key"]
343+
required = ["endpoint", "bucket"]
344344
missing = [k for k in required if not self.spec.get(k)]
345345
if missing:
346346
raise errors.DataJointError(f"Missing S3 configuration: {', '.join(missing)}")
347+
# access_key/secret_key are optional: when both are absent the
348+
# underlying botocore credential chain resolves an ambient identity
349+
# (instance profile, IRSA, ECS task role, SSO), matching gcs/azure.
350+
# But botocore treats exactly one as a partial credential and fails
351+
# late (PartialCredentialsError at first access), so reject that here
352+
# with a clear message.
353+
if bool(self.spec.get("access_key")) != bool(self.spec.get("secret_key")):
354+
raise errors.DataJointError(
355+
"Incomplete S3 credentials: set both access_key and secret_key, "
356+
"or neither to use ambient AWS credentials."
357+
)
347358

348359
@property
349360
def fs(self) -> fsspec.AbstractFileSystem:
@@ -376,10 +387,14 @@ def _create_filesystem(self) -> fsspec.AbstractFileSystem:
376387
else:
377388
endpoint_url = endpoint
378389

390+
# Coerce falsy (missing or empty-string) credentials to None so s3fs
391+
# drops them and botocore falls through to the default chain. A
392+
# forwarded "" is NOT equivalent: it survives s3fs's None-filter and
393+
# botocore reads it as an explicit (invalid) credential.
379394
return fsspec.filesystem(
380395
"s3",
381-
key=self.spec["access_key"],
382-
secret=self.spec["secret_key"],
396+
key=self.spec.get("access_key") or None,
397+
secret=self.spec.get("secret_key") or None,
383398
client_kwargs={"endpoint_url": endpoint_url},
384399
)
385400

tests/unit/test_settings.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,37 @@ def test_get_store_spec_file_protocol(self):
336336
finally:
337337
dj.config.stores = original_stores
338338

339+
def test_get_store_spec_s3_without_credentials(self):
340+
"""s3 no longer requires access_key/secret_key (#1537) — matches gcs/azure."""
341+
original_stores = dj.config.stores.copy()
342+
try:
343+
dj.config.stores["test_s3"] = {
344+
"protocol": "s3",
345+
"endpoint": "s3.amazonaws.com",
346+
"bucket": "my-bucket",
347+
"location": "prefix",
348+
}
349+
spec = dj.config.get_store_spec("test_s3")
350+
assert spec["protocol"] == "s3"
351+
assert "access_key" not in spec and "secret_key" not in spec
352+
finally:
353+
dj.config.stores = original_stores
354+
355+
def test_get_store_spec_s3_missing_bucket(self):
356+
"""endpoint/bucket/location stay required for s3."""
357+
original_stores = dj.config.stores.copy()
358+
try:
359+
dj.config.stores["bad_s3"] = {
360+
"protocol": "s3",
361+
"endpoint": "s3.amazonaws.com",
362+
"location": "prefix",
363+
# missing bucket
364+
}
365+
with pytest.raises(DataJointError, match="missing"):
366+
dj.config.get_store_spec("bad_s3")
367+
finally:
368+
dj.config.stores = original_stores
369+
339370
def test_get_store_spec_missing_required(self):
340371
"""Test missing required keys raises error."""
341372
original_stores = dj.config.stores.copy()

tests/unit/test_storage_adapter.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,3 +336,61 @@ def _fake_entry_points(*, group=None):
336336
assert adapter is not None
337337
assert sa_mod.get_storage_adapter("bad") is None
338338
assert any("bad" in rec.message and "boom" in rec.message for rec in caplog.records)
339+
340+
341+
class TestS3AmbientCredentials:
342+
"""s3 stores may omit access_key/secret_key and fall through to the
343+
botocore credential chain, matching gcs/azure (#1537)."""
344+
345+
@staticmethod
346+
def _backend(spec):
347+
backend = StorageBackend.__new__(StorageBackend)
348+
backend.spec = {"protocol": "s3", "endpoint": "s3.amazonaws.com", "bucket": "b", **spec}
349+
backend.protocol = "s3"
350+
backend._fs = None
351+
return backend
352+
353+
def _captured_kwargs(self, monkeypatch, spec):
354+
captured = {}
355+
356+
def fake_filesystem(protocol, **kwargs):
357+
captured["protocol"] = protocol
358+
captured.update(kwargs)
359+
return object()
360+
361+
monkeypatch.setattr(storage.fsspec, "filesystem", fake_filesystem)
362+
self._backend(spec)._create_filesystem()
363+
return captured
364+
365+
def test_no_credentials_validates(self):
366+
# both absent is valid — ambient identity resolves downstream
367+
self._backend({})._validate_spec()
368+
369+
def test_no_credentials_forwards_none(self, monkeypatch):
370+
kw = self._captured_kwargs(monkeypatch, {})
371+
assert kw["key"] is None and kw["secret"] is None
372+
373+
def test_both_credentials_forwarded(self, monkeypatch):
374+
kw = self._captured_kwargs(monkeypatch, {"access_key": "AK", "secret_key": "SK"})
375+
assert kw["key"] == "AK" and kw["secret"] == "SK"
376+
377+
def test_empty_string_treated_as_absent(self, monkeypatch):
378+
# "" survives s3fs's None-filter and botocore reads it as an explicit
379+
# (invalid) credential, so it must be coerced to None
380+
self._backend({"access_key": "", "secret_key": ""})._validate_spec()
381+
kw = self._captured_kwargs(monkeypatch, {"access_key": "", "secret_key": ""})
382+
assert kw["key"] is None and kw["secret"] is None
383+
384+
def test_partial_credentials_rejected(self):
385+
with pytest.raises(DataJointError, match="Incomplete S3 credentials"):
386+
self._backend({"access_key": "AK"})._validate_spec()
387+
with pytest.raises(DataJointError, match="Incomplete S3 credentials"):
388+
self._backend({"secret_key": "SK"})._validate_spec()
389+
390+
def test_missing_endpoint_or_bucket_still_required(self):
391+
backend = StorageBackend.__new__(StorageBackend)
392+
backend.spec = {"protocol": "s3", "bucket": "b"} # no endpoint
393+
backend.protocol = "s3"
394+
backend._fs = None
395+
with pytest.raises(DataJointError, match="Missing S3 configuration"):
396+
backend._validate_spec()

0 commit comments

Comments
 (0)